[ 
https://issues.apache.org/jira/browse/GROOVY-12124?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18093143#comment-18093143
 ] 

ASF GitHub Bot commented on GROOVY-12124:
-----------------------------------------

Copilot commented on code in PR #2653:
URL: https://github.com/apache/groovy/pull/2653#discussion_r3510877746


##########
subprojects/groovy-dateutil/src/main/java/org/apache/groovy/dateutil/BaseDuration.java:
##########
@@ -0,0 +1,187 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  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 org.apache.groovy.dateutil;
+
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Base class for date and time durations (dequirked, {@code 
java.util.Date}-flavored).
+ * <p>
+ * This is the B1 prototype: the same value-class model as the legacy
+ * {@code groovy.time.BaseDuration}, but with the historical quirks removed:
+ * <ul>
+ *   <li><b>ago / from.now no longer zero the time-of-day</b> for date-based 
durations —
+ *       the behavior is now uniform across all durations (time is preserved), 
so a single
+ *       implementation lives here rather than divergent overrides per 
subclass.</li>
+ *   <li><b>{@link #toMilliseconds()} is deterministic</b> — datum-dependent 
amounts (years,
+ *       months) use {@link ChronoUnit}'s estimated durations rather than 
resolving against
+ *       {@code new Date()} ("now"), so the result no longer depends on when 
it is called.</li>
+ * </ul>
+ *
+ * @see Duration
+ */
+public abstract class BaseDuration implements Comparable<BaseDuration> {
+    /** Estimated milliseconds in a year (ChronoUnit.YEARS = 365.2425 days). */
+    static final long MILLIS_PER_YEAR = 
ChronoUnit.YEARS.getDuration().toMillis();
+    /** Estimated milliseconds in a month (ChronoUnit.MONTHS = 30.436875 
days). */
+    static final long MILLIS_PER_MONTH = 
ChronoUnit.MONTHS.getDuration().toMillis();
+
+    protected final int years;
+    protected final int months;
+    protected final int days;
+    protected final int hours;
+    protected final int minutes;
+    protected final int seconds;
+    protected final int millis;
+
+    protected BaseDuration(final int years, final int months, final int days, 
final int hours, final int minutes, final int seconds, final int millis) {
+        this.years = years;
+        this.months = months;
+        this.days = days;
+        this.hours = hours;
+        this.minutes = minutes;
+        this.seconds = seconds;
+        this.millis = millis;
+    }
+
+    protected BaseDuration(final int days, final int hours, final int minutes, 
final int seconds, final int millis) {
+        this(0, 0, days, hours, minutes, seconds, millis);
+    }
+
+    public int getYears()   { return this.years; }
+    public int getMonths()  { return this.months; }
+    public int getDays()    { return this.days; }
+    public int getHours()   { return this.hours; }
+    public int getMinutes() { return this.minutes; }
+    public int getSeconds() { return this.seconds; }
+    public int getMillis()  { return this.millis; }
+
+    /**
+     * Adds this duration to the supplied date.
+     */
+    public Date plus(final Date date) {
+        final Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.add(Calendar.YEAR, this.years);
+        cal.add(Calendar.MONTH, this.months);
+        cal.add(Calendar.DAY_OF_YEAR, this.days);
+        cal.add(Calendar.HOUR_OF_DAY, this.hours);
+        cal.add(Calendar.MINUTE, this.minutes);
+        cal.add(Calendar.SECOND, this.seconds);
+        cal.add(Calendar.MILLISECOND, this.millis);
+        return cal.getTime();
+    }
+
+    /**
+     * Converts this duration to milliseconds.
+     * <p>
+     * DEQUIRKED (B): deterministic. Years and months use {@link ChronoUnit} 
estimates
+     * (a year is exactly 12 estimated months, so {@code 1.year == 
12.months}); days and
+     * below are exact assuming 24-hour days. The result never depends on the 
current date.
+     */
+    public long toMilliseconds() {
+        return years * MILLIS_PER_YEAR
+                + months * MILLIS_PER_MONTH
+                + days * 86_400_000L
+                + hours * 3_600_000L
+                + minutes * 60_000L
+                + seconds * 1_000L
+                + millis;
+    }
+
+    /**
+     * The date this duration ago.
+     * <p>
+     * DEQUIRKED (A): the time-of-day is preserved (no flooring to midnight),
+     * uniformly for all duration kinds.
+     */
+    public Date getAgo() {
+        final Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.YEAR, -this.years);
+        cal.add(Calendar.MONTH, -this.months);
+        cal.add(Calendar.DAY_OF_YEAR, -this.days);
+        cal.add(Calendar.HOUR_OF_DAY, -this.hours);
+        cal.add(Calendar.MINUTE, -this.minutes);
+        cal.add(Calendar.SECOND, -this.seconds);
+        cal.add(Calendar.MILLISECOND, -this.millis);
+        return cal.getTime();
+    }
+
+    /**
+     * Helper for computing dates relative to the current time.
+     * DEQUIRKED (A): time-of-day preserved.
+     */
+    public From getFrom() {
+        return new From() {
+            @Override
+            public Date getNow() {
+                final Calendar cal = Calendar.getInstance();
+                cal.add(Calendar.YEAR, BaseDuration.this.years);
+                cal.add(Calendar.MONTH, BaseDuration.this.months);
+                cal.add(Calendar.DAY_OF_YEAR, BaseDuration.this.days);
+                cal.add(Calendar.HOUR_OF_DAY, BaseDuration.this.hours);
+                cal.add(Calendar.MINUTE, BaseDuration.this.minutes);
+                cal.add(Calendar.SECOND, BaseDuration.this.seconds);
+                cal.add(Calendar.MILLISECOND, BaseDuration.this.millis);
+                return cal.getTime();
+            }
+        };
+    }
+
+    @Override
+    public int compareTo(BaseDuration other) {
+        return Long.signum(toMilliseconds() - other.toMilliseconds());
+    }

Review Comment:
   `compareTo` uses subtraction (`toMilliseconds() - other.toMilliseconds()`), 
which can overflow and return the wrong sign for large values, breaking the 
`Comparable` contract (inconsistent ordering in sorted collections). Prefer 
`Long.compare` to avoid overflow in the comparison step.



##########
subprojects/groovy-dateutil/src/test/groovy/org/apache/groovy/dateutil/TimeCategoryTest.groovy:
##########
@@ -0,0 +1,282 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  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 org.apache.groovy.dateutil
+
+import org.junit.jupiter.api.Test
+
+import static java.util.Calendar.DAY_OF_YEAR
+import static java.util.Calendar.MONTH
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Tests the {@link org.apache.groovy.dateutil.TimeCategory} class, the 
dequirked
+ * {@code java.util.Date}-flavored parallel to the legacy {@code 
groovy.time.TimeCategory}.
+ * The arithmetic assertions are ported verbatim from the legacy suite; the 
final section
+ * exercises the two behaviors that were intentionally changed ("dequirked").
+ */
+class TimeCategoryTest {
+
+    @Test
+    void testDurationArithmeticOnMilliseconds() {
+        use(TimeCategory) {
+            def midnight = new Date(100, 0, 1, 0, 0, 0)
+            def oneSecondPastMidnight = new Date(100, 0, 1, 0, 0, 1)
+            def twoSecondsPastMidnight = new Date(100, 0, 1, 0, 0, 2)
+
+            assert (midnight + 1000.millisecond) == oneSecondPastMidnight
+            assert (midnight + 2000.milliseconds) == twoSecondsPastMidnight
+            assert (twoSecondsPastMidnight - 1000.millisecond) == 
oneSecondPastMidnight
+            assert (twoSecondsPastMidnight - 2000.milliseconds) == midnight
+        }
+    }
+
+    @Test
+    void testDurationArithmeticOnSeconds() {
+        use(TimeCategory) {
+            def midnight = new Date(100, 0, 1, 0, 0, 0)
+            def oneSecondPastMidnight = new Date(100, 0, 1, 0, 0, 1)
+            def twoSecondsPastMidnight = new Date(100, 0, 1, 0, 0, 2)
+
+            assert (midnight + 1.second) == oneSecondPastMidnight
+            assert (midnight + 2.seconds) == twoSecondsPastMidnight
+            assert (twoSecondsPastMidnight - 1.second) == oneSecondPastMidnight
+            assert (twoSecondsPastMidnight - 2.seconds) == midnight
+        }
+    }
+
+    @Test
+    void testDurationArithmeticOnMinutes() {
+        use(TimeCategory) {
+            def midnight = new Date(100, 0, 1, 0, 0, 0)
+            def oneMinutePastMidnight = new Date(100, 0, 1, 0, 1, 0)
+            def twoMinutesPastMidnight = new Date(100, 0, 1, 0, 2, 0)
+
+            assert (midnight + 1.minute) == oneMinutePastMidnight
+            assert (midnight + 2.minutes) == twoMinutesPastMidnight
+            assert (twoMinutesPastMidnight - 60.seconds) == 
oneMinutePastMidnight
+            assert (twoMinutesPastMidnight - 1.minute) == oneMinutePastMidnight
+            assert (twoMinutesPastMidnight - 120.seconds) == midnight
+            assert (twoMinutesPastMidnight - 2.minutes) == midnight
+        }
+    }
+
+    @Test
+    void testDurationArithmeticOnHours() {
+        use(TimeCategory) {
+            def midnight = new Date(100, 0, 1, 0, 0, 0)
+            def oneAM = new Date(100, 0, 1, 1, 0, 0)
+            def twoAM = new Date(100, 0, 1, 2, 0, 0)
+
+            assert (midnight + 1.hour) == oneAM
+            assert (midnight + 2.hours) == twoAM
+            assert (twoAM - 3600.seconds) == oneAM
+            assert (twoAM - 1.hour) == oneAM
+            assert (twoAM - 7200.seconds) == midnight
+            assert (twoAM - 2.hours) == midnight
+        }
+    }
+
+    @Test
+    void testDurationArithmeticOnDays() {
+        use(TimeCategory) {
+            def januaryFirst = new Date(100, 0, 1, 0, 0, 0)
+            def januarySecond = new Date(100, 0, 2, 0, 0, 0)
+            def januaryThird = new Date(100, 0, 3, 0, 0, 0)
+
+            assert (januaryFirst + 1.day) == januarySecond
+            assert (januaryFirst + 2.days) == januaryThird
+            assert (januaryThird - 1.day) == januarySecond
+            assert (januaryThird - 2.days) == januaryFirst
+        }
+    }
+
+    @Test
+    void testDurationArithmeticOnWeeks() {
+        use(TimeCategory) {
+            def firstWeek = new Date(100, 0, 1, 0, 0, 0)
+            def secondWeek = new Date(100, 0, 8, 0, 0, 0)
+            def thirdWeek = new Date(100, 0, 15, 0, 0, 0)
+
+            assert (firstWeek + 1.week) == secondWeek
+            assert (firstWeek + 2.weeks) == thirdWeek
+            assert (thirdWeek - 1.week) == secondWeek
+            assert (thirdWeek - 2.weeks) == firstWeek
+        }
+    }
+
+    @Test
+    void testDurationArithmeticOnMonths() {
+        use(TimeCategory) {
+            def january = new Date(100, 0, 1, 0, 0, 0)
+            def february = new Date(100, 1, 1, 0, 0, 0)
+            def march = new Date(100, 2, 1, 0, 0, 0)
+
+            assert (january + 1.month) == february
+            assert (january + 2.months) == march
+            assert (march - 1.month) == february
+            assert (march - 2.months) == january
+        }
+    }
+
+    @Test
+    void testDurationArithmeticOnYears() {
+        use(TimeCategory) {
+            def firstYear = new Date(100, 0, 1, 0, 0, 0)
+            def secondYear = new Date(101, 0, 1, 0, 0, 0)
+            def thirdYear = new Date(102, 0, 1, 0, 0, 0)
+
+            assert (firstYear + 1.year) == secondYear
+            assert (firstYear + 2.years) == thirdYear
+            assert (thirdYear - 1.year) == secondYear
+            assert (thirdYear - 2.years) == firstYear
+        }
+    }
+
+    @Test
+    void testDateSubtractionOnSeconds() {
+        use(TimeCategory) {
+            def current = new Date(100, 0, 1, 0, 0, 0)
+            def oneSecondLater = new Date(100, 0, 1, 0, 0, 1)
+            def twoSecondsLater = new Date(100, 0, 1, 0, 0, 2)
+
+            assert (oneSecondLater - current).seconds == 1
+            assert (twoSecondsLater - current).seconds == 2
+        }
+    }
+
+    @Test
+    void testDateSubtractionOnMinutes() {
+        use(TimeCategory) {
+            def current = new Date(100, 0, 1, 0, 0, 0)
+            assert (new Date(100, 0, 1, 0, 1, 0) - current).minutes == 1
+            assert (new Date(100, 0, 1, 0, 2, 0) - current).minutes == 2
+        }
+    }
+
+    @Test
+    void testDateSubtractionOnHours() {
+        use(TimeCategory) {
+            def current = new Date(100, 0, 1, 0, 0, 0)
+            assert (new Date(100, 0, 1, 1, 0, 0) - current).hours == 1
+            assert (new Date(100, 0, 1, 2, 0, 0) - current).hours == 2
+        }
+    }
+
+    @Test
+    void testDateSubtractionOnDays() {
+        use(TimeCategory) {
+            def current = new Date(100, 0, 1, 0, 0, 0)
+            assert (new Date(100, 0, 2, 0, 0, 0) - current).days == 1
+            assert (new Date(100, 0, 3, 0, 0, 0) - current).days == 2
+        }
+    }
+
+    @Test
+    void testDateSubtraction_NoYearsOrMonths() {
+        use(TimeCategory) {
+            def result = new Date(102, 0, 1, 0, 0, 0) - new Date(100, 0, 1, 0, 
0, 0)
+            // date subtraction does not populate months and years
+            assert result.years == 0
+            assert result.months == 0
+        }
+    }
+
+    @Test
+    void testToStringForNegativeValues() {
+        use(TimeCategory) {
+            def t1 = Calendar.instance.time
+            def t2 = t1 - 4.seconds + 2.milliseconds
+            def t3 = t1 + 4.seconds + 2.milliseconds
+            def t4 = t1 - 4.seconds - 2.milliseconds
+            def t5 = t1 + 4.seconds - 2.milliseconds
+            def t6 = t1 - 2.milliseconds
+            def t7 = t1 + 2.milliseconds
+            assert (t1 - t2).toString() == '3.998 seconds'
+            assert (t1 - t3).toString() == '-4.002 seconds'
+            assert (t1 - t4).toString() == '4.002 seconds'
+            assert (t1 - t5).toString() == '-3.998 seconds'
+            assert (t1 - t6).toString() == '0.002 seconds'
+            assert (t1 - t7).toString() == '-0.002 seconds'
+        }
+    }
+
+    @Test
+    void testToStringForOverflow() {
+        use(TimeCategory) {
+            def t = 800.milliseconds + 300.milliseconds
+            assert t.toString() == '1.100 seconds'
+        }
+    }
+
+    @Test
+    void testZeroDurationFromNowIsNow() {
+        use(TimeCategory) {
+            // Replaces the legacy testDateEquality, whose reproducibility 
relied on from.now
+            // flooring to midnight. Dequirked, a zero-length duration from 
now is (approximately)
+            // now, with the time-of-day preserved.
+            def reference = System.currentTimeMillis()
+            Date result = 0.days.from.now
+            assert Math.abs(result.time - reference) < 2000
+        }

Review Comment:
   This assertion can be flaky under heavy load because it assumes the 
`.from.now` call happens within 2 seconds of capturing `reference`. Measuring 
`before`/`after` around the call makes the test robust regardless of scheduling 
delays.



##########
subprojects/groovy-dateutil/src/test/groovy/org/apache/groovy/dateutil/DurationTest.groovy:
##########
@@ -0,0 +1,171 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  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 org.apache.groovy.dateutil
+
+import org.junit.jupiter.api.Test
+
+import java.time.temporal.ChronoUnit
+
+import static java.util.Calendar.DAY_OF_YEAR
+import static java.util.Calendar.MONTH
+import static java.util.Calendar.WEEK_OF_YEAR
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNotNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Tests for the dequirked {@code org.apache.groovy.dateutil} Duration 
hierarchy.
+ */
+class DurationTest {
+
+    @Test
+    void testFixedDurationArithmetic() {
+        use(TimeCategory) {
+            def oneDay = 2.days - 1.day
+            assert oneDay.toMilliseconds() == (24 * 60 * 60 * 1000)
+
+            oneDay = 2.days - 1.day + 24.hours - 1440.minutes
+            assert oneDay.toMilliseconds() == (24 * 60 * 60 * 1000)
+        }
+    }
+
+    @Test
+    void testDurationToString() {
+        use(TimeCategory) {
+            assert (0.years).toString() == "0"
+            assert (3.years + 8.months + 4.days + 2.hours + 5.minutes + 
12.milliseconds).toString() ==
+                    "3 years, 8 months, 4 days, 2 hours, 5 minutes, 0.012 
seconds"
+            assert (4.days + 2.hours + 5.minutes + 12.milliseconds).toString() 
== "4 days, 2 hours, 5 minutes, 0.012 seconds"
+            assert (4.days + 2.hours + 5.minutes).toString() == "4 days, 2 
hours, 5 minutes"
+            assert (5.seconds).toString() == "5.000 seconds"
+            assert ((-12).milliseconds).toString() == "-0.012 seconds"
+            assert (5.seconds + 12.milliseconds).toString() == "5.012 seconds"
+            assert (5.seconds + 1012.milliseconds).toString() == "6.012 
seconds"
+        }
+    }
+
+    @Test
+    void testDurationComparison() {
+        use(TimeCategory) {
+            assert 1.week < 2.weeks
+            assert 3.weeks <= 4.weeks
+            assert 10.seconds == 10.seconds
+            assert 4.months >= 1.month
+            assert 10.days > 2.days
+            assert 3.months > 2.months
+            assert 1.second < 1.year
+            assert 1.day > 1000.seconds
+            assert 10.weeks < 10.years
+        }
+    }
+
+    @Test
+    void testConstructor() {
+        def duration = new Duration(1, 2, 3, 4, 5)
+        assertEquals(1, duration.days)
+        assertEquals(2, duration.hours)
+        assertEquals(3, duration.minutes)
+        assertEquals(4, duration.seconds)
+        assertEquals(5, duration.millis)
+    }
+
+    @Test
+    void testToMilliseconds() {
+        assertEquals(24 * 60 * 60 * 1000L, new Duration(1, 0, 0, 0, 
0).toMilliseconds())
+        def d = new Duration(1, 2, 3, 4, 5)
+        assertEquals(((((1L * 24 + 2) * 60 + 3) * 60 + 4) * 1000) + 5, 
d.toMilliseconds())
+    }
+
+    @Test
+    void testDatumDependentToMillisecondsIsDeterministic() { // DEQUIRK B
+        // years/months use fixed ChronoUnit estimates; no dependence on the 
current date
+        assertEquals(ChronoUnit.YEARS.duration.toMillis(), new 
DatumDependentDuration(1, 0, 0, 0, 0, 0, 0).toMilliseconds())
+        assertEquals(ChronoUnit.MONTHS.duration.toMillis(), new 
DatumDependentDuration(0, 1, 0, 0, 0, 0, 0).toMilliseconds())
+        // a year is exactly twelve estimated months
+        assertEquals(new DatumDependentDuration(1, 0, 0, 0, 0, 0, 
0).toMilliseconds(),
+                new DatumDependentDuration(0, 12, 0, 0, 0, 0, 
0).toMilliseconds())
+    }
+
+    @Test
+    void testPlusDuration() {
+        def result = new Duration(1, 2, 3, 4, 5).plus(new Duration(1, 1, 1, 1, 
1))
+        assertEquals(2, result.days); assertEquals(3, result.hours); 
assertEquals(4, result.minutes)
+        assertEquals(5, result.seconds); assertEquals(6, result.millis)
+    }
+
+    @Test
+    void testPlusDatumDependentDuration() {
+        def result = new Duration(1, 0, 0, 0, 0).plus(new 
DatumDependentDuration(1, 2, 3, 4, 5, 6, 7))
+        assertNotNull(result)
+        assertEquals(1, result.years); assertEquals(2, result.months)
+    }
+
+    @Test
+    void testMinusDatumDependentDuration() {
+        def result = new Duration(10, 5, 30, 20, 100).minus(new 
DatumDependentDuration(1, 2, 3, 1, 10, 5, 50))
+        assertEquals(-1, result.years); assertEquals(-2, result.months); 
assertEquals(7, result.days)
+    }
+
+    @Test
+    void testGetAgoPreservesTimeOfDay() { // DEQUIRK A
+        def ago = new Duration(1, 0, 0, 0, 0).getAgo()
+        def expected = Calendar.instance
+        expected.add(DAY_OF_YEAR, -1)
+        assertTrue Math.abs(expected.timeInMillis - ago.time) < 2000
+    }
+
+    @Test
+    void testGetFromPreservesTimeOfDay() { // DEQUIRK A
+        def now = new Duration(3, 0, 0, 0, 0).from.now
+        def expected = Calendar.instance
+        expected.add(DAY_OF_YEAR, 3)
+        assertTrue Math.abs(expected.timeInMillis - now.time) < 2000
+    }
+
+    @Test
+    void testMinutesAgo() { // See GROOVY-3687 - was already un-quirky for 
time durations
+        use(TimeCategory) {
+            def now = Calendar.instance
+            def before = 10.minutes.ago
+            now.add(Calendar.MINUTE, -11)
+            assertTrue now.timeInMillis < before.time
+        }
+    }
+
+    @Test
+    void testDatumDependantArithmetic() {

Review Comment:
   Method name uses the misspelling "Dependant" (vs "Dependent" as in 
`DatumDependentDuration`). Renaming keeps terminology consistent across the 
codebase and avoids propagating the typo into test reports.





> Modernize the TimeCategory date/time DSL: add a java.time flavor and a 
> dequirked java.util.Date flavor
> ------------------------------------------------------------------------------------------------------
>
>                 Key: GROOVY-12124
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12124
>             Project: Groovy
>          Issue Type: Improvement
>            Reporter: Paul King
>            Priority: Major
>
> h1. Modernize the TimeCategory date/time DSL
> h2. Summary
> Introduce a {{java.time}}-based parallel to the legacy 
> {{groovy.time.TimeCategory}}
> DSL, and give the existing {{java.util.Date}}-based DSL a cleaned-up 
> ("dequirked")
> home in the module that already owns Date support. Net result: one familiar 
> DSL
> ({{1.hour.ago}}, {{date + 3.months}}, {{2.days.from.now}}) available in two 
> flavors —
> modern {{java.time}} output or classic {{java.util.Date}} output — both 
> quirk-free.
> h2. Background
> {{groovy.time.TimeCategory}} (a {{use()}} category in *groovy core*) layers 
> duration
> arithmetic onto {{java.util.Date}}/{{Calendar}}: {{Integer}} producers 
> ({{1.hour}},
> {{2.months}}), operators ({{date + duration}}, {{date - date}}), and 
> relative-time
> properties ({{.ago}}, {{.from.now}}). It is backed by the 
> {{groovy.time.Duration}}
> hierarchy (Duration / TimeDuration / DatumDependentDuration / 
> TimeDatumDependentDuration).
> Two problems motivate this work:
> * *No java.time equivalent.* Modern code uses {{java.time}}, but there is no
>   {{1.hour}}/{{2.months}} producer for it. The {{groovy-datetime}} module 
> already
>   provides all the arithmetic (plus/minus/next/multiply/between/upto…) on 
> {{java.time}}
>   types — the only gap is the number→amount producers and 
> {{.ago}}/{{.from.now}}.
> * *The Date DSL is split and quirky.* All other {{java.util.Date}} DSL methods
>   ({{date + int}}, {{date.next()}}, {{date[YEAR]}}, {{clearTime}}, {{format}},
>   {{date - date -> int}}) live in the optional *groovy-dateutil* module, while
>   TimeCategory lives in core — so half of Date arithmetic is core, half is 
> optional,
>   and {{date - date}} even resolves differently between them.
> h2. Proposal
> Add two categories and deprecate the legacy one:
> || Class || Module || Output type || Status ||
> | {{org.apache.groovy.datetime.TimeCategory}} | groovy-datetime | 
> {{java.time}} (Duration/Period, LocalDate/LocalDateTime) | new |
> | {{org.apache.groovy.dateutil.TimeCategory}} | groovy-dateutil | 
> {{java.util.Date}} | new (dequirked) |
> | {{groovy.time.TimeCategory}} (+ Duration hierarchy) | core | 
> {{java.util.Date}} | deprecated, frozen |
> The two new categories share one DSL surface and one semantics spec; only the 
> terminal
> return types differ. The legacy class stays byte-for-byte behavior-compatible 
> (frozen),
> so existing users are unaffected until they choose to migrate.
> h3. Producer mapping (both flavors)
> * {{seconds, minutes, hours, millis, nanos}} → {{java.time.Duration}} 
> (datetime) /
>   {{TimeDuration}} (dateutil)
> * {{days, weeks, months, years}} → {{java.time.Period}} (datetime) /
>   {{Duration}}/{{DatumDependentDuration}} (dateutil)
> * {{.ago}} / {{.from.now}} → {{LocalDate}}/{{LocalDateTime}} (datetime) /
>   {{java.util.Date}} (dateutil)
> Note: {{java.time}} deliberately keeps date-based ({{Period}}) and time-based
> ({{Duration}}) amounts separate, so {{date + 2.months + 3.hours}} works by
> left-associative chaining rather than a combined amount type.
> h2. Design decisions
> * *(proposed, pending team review)* The dateutil flavor uses its *own* 
> dequirked value
>   classes (option "B1"), not {{java.time}} internally (option "B2"). 
> Rationale: B1 keeps
>   the DSL return shape ({{.seconds}}/{{.days}} component accessors, bespoke 
> {{toString}},
>   the type lattice), so existing tests and user code port ~1:1; it is fully 
> self-contained
>   within groovy-dateutil (no dependency on groovy-datetime being on the 
> classpath — matching
>   today, where the Duration classes rely on no external extension methods). 
> B2 would change
>   component-accessor and {{toString}} semantics and require either 
> duplicating datetime's
>   operator DGM or a new dateutil→datetime module dependency.
> * *(proposed, pending team review)* Deprecate {{groovy.time.TimeCategory}} 
> with a removal
>   trajectory (future major). The dateutil copy gives Date users a 
> non-deprecated home, so
>   the deprecation is not coercive toward java.time.
> * Keep the legacy {{groovy.time.Duration}} value classes exactly as-is (do 
> not move; the
>   dequirked classes are new copies in {{org.apache.groovy.dateutil}} — no 
> split package).
> * The legacy class is *not* a forwarder to the new one (behavior differs); it 
> stays intact.
> h2. Quirks removed in the new flavors
> || # || Legacy behavior || New behavior ||
> | A | {{.ago}}/{{.from.now}} floor to midnight for day/month/year durations 
> but keep time for hours/min/sec (inconsistent) | Time-of-day preserved 
> uniformly (datetime: {{Period}}→{{LocalDate}}, 
> {{Duration}}→{{LocalDateTime}}) |
> | B | {{DatumDependentDuration.toMilliseconds()}} resolves against {{new 
> Date()}} — nondeterministic | Deterministic: uses {{ChronoUnit}} estimates 
> ({{1.year == 12.months}} still holds exactly); {{java.time}} {{Period}} 
> simply has no {{toMillis}} |
> | C | Four-class Duration lattice incl. {{TimeDatumDependentDuration}} | 
> Retained in dateutil (B1); not needed in datetime (chaining) |
> | D | {{Duration}} = 24h/day for {{toMillis}} but DST-aware when added | 
> Documented/consistent |
> | E | {{getTimeZone}} (already {{@Deprecated}}), {{getDaylightSavingsOffset}} 
> | Dropped from new flavors (superseded by zone-aware java.time) |
> Side effect of A+B: {{getAgo}}/{{getFrom}}/{{toMilliseconds}} collapse to 
> single
> implementations on {{BaseDuration}}, removing the per-subclass overrides 
> (~40% less
> duplication in the value classes).
> h2. Prototype validation
> A working B1 prototype of the dateutil flavor (six classes) was built and 
> exercised
> against a port of {{TimeCategoryTest}}:
> * All arithmetic/{{toString}}/comparison assertions ported with *only* the 
> {{use()}}/import
>   target changed — no assertion edits.
> * Three added assertions demonstrate the dequirks; the same probes run 
> against the legacy
>   class confirm the delta (legacy {{3.days.ago}} → {{00:00:00}}; 
> {{1.month.ago}} off by the
>   millis-since-midnight; {{5.months.toMilliseconds()}} varies with the 
> current date).
> * Only the value-class suites ({{DurationTest}}, 
> {{DatumDependentDurationTest}}) need edits,
>   confined to the ~6 methods that assert the midnight-flooring / 
> {{now}}-relative behavior.
> h2. Scope / tasks
> * [ ] {{org.apache.groovy.datetime.TimeCategory}} + tests + spec section 
> (_working-with-datetime-types_)
> * [ ] {{org.apache.groovy.dateutil.TimeCategory}} (B1, dequirked) + value 
> classes + tests + spec section
> * [ ] Deprecate {{groovy.time.TimeCategory}} (Javadoc {{@deprecated}} 
> pointing to both replacements)
> * [ ] Reconcile/document the {{date - date}} collision (int days vs Duration) 
> within groovy-dateutil
> * [ ] Add {{Long}}/{{nanos}} support (enhancement over the Integer-only 
> legacy)
> * [ ] Changelog + docgenerator entries
> h2. Open questions (pending team review)
> * B1 vs B2 for the dateutil flavor (recommendation: B1).
> * Commit to eventual removal of {{groovy.time.TimeCategory}} (hard 
> {{@Deprecated}}), or
>   soft-deprecate in docs only?
> * Category name: reuse the simple name {{TimeCategory}} in both new packages 
> (symmetry, but
>   dual-import hazard) vs a distinct name for the datetime flavor.
> * Global DGM producers (static-compilation friendly) in addition to the 
> {{use()}} category —
>   now, later, or never?



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to