Repository: codeigniter4/CodeIgniter4
Pull request #9528: feat: add `Time::addCalendarMonths()` and `Time::subCalendarMonths()` methods
Author: login: christianberkman
Labels: name: enhancement
description: PRs that improve existing functionalities
Created: 2025-04-17T21:25:11Z | Merged: 2025-04-25T14:12:18Z

<!--

Each pull request should address a single issue and have a meaningful title.

- PR title must include the type (feat, fix, chore, docs, perf, refactor, style, test) of the commit per Conventional Commits specification. See https://www.conventionalcommits.org/en/v1.0.0/ for the discussion.
- Pull requests must be in English.
- If a pull request fixes an issue, reference the issue with a suitable keyword (e.g., Fixes <issue number>).
- All bug fixes should be sent to the __"develop"__ branch, this is where the next bug fix version will be developed.
- PRs with any enhancement should be sent to the next minor version branch, e.g. __"4.5"__

-->
The `Time::addMonths()` could return an undesired behaviour if a date in the next calandar month is desired. For example, `Time(2025-01-31)->addMonths(1)` results in `2025-03-03` while a date in february may be desired, or `Time(2025-03-31)->addMonths(1)` results in a date in May not in April.

The `addCalendarMonths` function returns a new date in the next calendar month.
_New_: Also implemented `subCalendarMonths` function to return a new date in a previous calendar month.

Of course, this only applies if the dev is looking for a date in the next calendar month (months are weird of course, it is 30, 31, 28, 29 days...? who knows).

If deemed worthy to include in 4.7 I will write test and documentation.

Comparison between `addMonths()` and `addCalendarMonths()`
```
Initial date: 2025-01-31
+---------+------------+-------------------+
| $months | addMonths  | addCalendarMonths |
+---------+------------+-------------------+
| 0       | 2025-01-31 | 2025-01-31        |
| 1       | 2025-03-03 | 2025-02-28        |
| 2       | 2025-03-31 | 2025-03-31        |
| 3       | 2025-05-01 | 2025-04-30        |
| 4       | 2025-05-31 | 2025-05-31        |
| 5       | 2025-07-01 | 2025-06-30        |
| 6       | 2025-07-31 | 2025-07-31        |
| 7       | 2025-08-31 | 2025-08-31        |
| 8       | 2025-10-01 | 2025-09-30        |
| 9       | 2025-10-31 | 2025-10-31        |
| 10      | 2025-12-01 | 2025-11-30        |
| 11      | 2025-12-31 | 2025-12-31        |
| 12      | 2026-01-31 | 2026-01-31        |
| 13      | 2026-03-03 | 2026-02-28        |
+---------+------------+-------------------+
````

**Checklist:**
- [x] Securely signed commits
- [x] Component(s) with PHPDoc blocks, only if necessary or adds value
- [x] Unit testing, with >80% coverage
- [x] User guide updated
- [x] Conforms to style guide





Diff:
diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php
index 42278c960c43..6d3e7d6bb98d 100644
--- a/system/I18n/TimeTrait.php
+++ b/system/I18n/TimeTrait.php
@@ -749,6 +749,39 @@ public function addMonths(int $months)
         return $time->add(DateInterval::createFromDateString("{$months} months"));
     }
 
+    /**
+     * Returns a new Time instance with $months calendar months added to the time.
+     */
+    public function addCalendarMonths(int $months): static
+    {
+        $time = clone $this;
+
+        $year  = (int) $time->getYear();
+        $month = (int) $time->getMonth();
+        $day   = (int) $time->getDay();
+
+        // Adjust total months since year 0
+        $totalMonths = ($year * 12 + $month - 1) + $months;
+
+        // Recalculate year and month
+        $newYear  = intdiv($totalMonths, 12);
+        $newMonth = $totalMonths % 12 + 1;
+
+        // Get last day of new month
+        $lastDayOfMonth = cal_days_in_month(CAL_GREGORIAN, $newMonth, $newYear);
+        $correctedDay   = min($day, $lastDayOfMonth);
+
+        return static::create($newYear, $newMonth, $correctedDay, (int) $this->getHour(), (int) $this->getMinute(), (int) $this->getSecond(), $this->getTimezone(), $this->locale);
+    }
+
+    /**
+     * Returns a new Time instance with $months calendar months subtracted from the time
+     */
+    public function subCalendarMonths(int $months): static
+    {
+        return $this->addCalendarMonths(-$months);
+    }
+
     /**
      * Returns a new Time instance with $years added to the time.
      *
diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php
index af45abcbe4b8..4f76636ac7eb 100644
--- a/tests/system/I18n/TimeTest.php
+++ b/tests/system/I18n/TimeTest.php
@@ -812,6 +812,22 @@ public function testCanAddMonthsOverYearBoundary(): void
         $this->assertSame('2018-02-10 13:20:33', $newTime->toDateTimeString());
     }
 
+    public function testCanAddCalendarMonths(): void
+    {
+        $time    = Time::parse('January 31, 2017 13:20:33', 'America/Chicago');
+        $newTime = $time->addCalendarMonths(1);
+        $this->assertSame('2017-01-31 13:20:33', $time->toDateTimeString());
+        $this->assertSame('2017-02-28 13:20:33', $newTime->toDateTimeString());
+    }
+
+    public function testCanAddCalendarMonthsOverYearBoundary(): void
+    {
+        $time    = Time::parse('January 31, 2017 13:20:33', 'America/Chicago');
+        $newTime = $time->addCalendarMonths(13);
+        $this->assertSame('2017-01-31 13:20:33', $time->toDateTimeString());
+        $this->assertSame('2018-02-28 13:20:33', $newTime->toDateTimeString());
+    }
+
     public function testCanAddYears(): void
     {
         $time    = Time::parse('January 10, 2017 13:20:33', 'America/Chicago');
@@ -860,6 +876,22 @@ public function testCanSubtractMonths(): void
         $this->assertSame('2016-10-10 13:20:33', $newTime->toDateTimeString());
     }
 
+    public function testCanSubtractCalendarMonths(): void
+    {
+        $time    = Time::parse('March 31, 2017 13:20:33', 'America/Chicago');
+        $newTime = $time->subCalendarMonths(1);
+        $this->assertSame('2017-03-31 13:20:33', $time->toDateTimeString());
+        $this->assertSame('2017-02-28 13:20:33', $newTime->toDateTimeString());
+    }
+
+    public function testCanSubtractCalendarMonthsOverYearBoundary(): void
+    {
+        $time    = Time::parse('March 31, 2017 13:20:33', 'America/Chicago');
+        $newTime = $time->subCalendarMonths(13);
+        $this->assertSame('2017-03-31 13:20:33', $time->toDateTimeString());
+        $this->assertSame('2016-02-29 13:20:33', $newTime->toDateTimeString());
+    }
+
     public function testCanSubtractYears(): void
     {
         $time    = Time::parse('January 10, 2017 13:20:33', 'America/Chicago');
diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst
index ae6797af7884..30bf101711e6 100644
--- a/user_guide_src/source/changelogs/v4.7.0.rst
+++ b/user_guide_src/source/changelogs/v4.7.0.rst
@@ -33,6 +33,11 @@ Method Signature Changes
 Enhancements
 ************
 
+Libraries
+=========
+
+- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()``
+
 Commands
 ========
 
diff --git a/user_guide_src/source/libraries/time.rst b/user_guide_src/source/libraries/time.rst
index 17af069c8c5f..c3ddb3c16d39 100644
--- a/user_guide_src/source/libraries/time.rst
+++ b/user_guide_src/source/libraries/time.rst
@@ -325,6 +325,24 @@ modify the existing Time instance, but will return a new instance.
 
 .. literalinclude:: time/031.php
 
+addCalendarMonths() / subCalendarMonths()
+-----------------------------------------
+
+Modifies the current Time by adding or subtracting whole calendar months. These methods can be useful if you 
+require no calendar months are skipped in recurring dates. Refer to the table below for a comparison between 
+``addMonths()`` and ``addCalendarMonths()`` for an initial date of ``2025-01-31``.
+
+======= =========== =================== 
+$months addMonths() addCalendarMonths()
+======= =========== ===================
+1       2025-03-03  2025-02-28          
+2       2025-03-31  2025-03-31          
+3       2025-05-01  2025-04-30       
+4       2025-05-31  2025-05-31
+5       2025-07-01  2025-06-30
+6       2025-07-31  2025-07-31
+======= =========== ===================
+
 Comparing Two Times
 ===================
 
diff --git a/user_guide_src/source/libraries/time/031.php b/user_guide_src/source/libraries/time/031.php
index 3737ae67a3c1..914ff279871d 100644
--- a/user_guide_src/source/libraries/time/031.php
+++ b/user_guide_src/source/libraries/time/031.php
@@ -5,6 +5,7 @@
 $time = $time->addHours(12);
 $time = $time->addDays(21);
 $time = $time->addMonths(14);
+$time = $time->addCalendarMonths(2);
 $time = $time->addYears(5);
 
 $time = $time->subSeconds(23);
@@ -12,4 +13,5 @@
 $time = $time->subHours(12);
 $time = $time->subDays(21);
 $time = $time->subMonths(14);
+$time = $time->subCalendarMonths(2);
 $time = $time->subYears(5);


Reviews:
user: login: christianberkman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T08:03:00Z
user: login: paulbalandan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T13:54:29Z
user: login: michalsn
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T14:55:57Z
user: login: christianberkman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T15:04:58Z
user: login: michalsn
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T15:09:04Z
user: login: michalsn
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T06:10:03Z
user: login: michalsn
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T06:26:43Z
user: login: paulbalandan
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T14:11:10Z

Review comments:
diff_hunk: @@ -330,11 +330,9 @@ public function toDateTime()
      * @param DateTimeInterface|self|string|null $datetime
      * @param DateTimeZone|string|null           $timezone
      *
-     * @return void
path: system/I18n/TimeTrait.php
user: login: christianberkman
body: This must have been a change from cs-fix.
created_at: 2025-04-21T08:03:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -330,11 +330,9 @@ public function toDateTime()
      * @param DateTimeInterface|self|string|null $datetime
      * @param DateTimeZone|string|null           $timezone
      *
-     * @return void
path: system/I18n/TimeTrait.php
user: login: paulbalandan
body: Please revert the fix from PHPDoc to native type (line 335). This can break someone when they override the `setTestNow` method.
created_at: 2025-04-21T13:46:45Z
author_association: MEMBER
diff_hunk: @@ -33,6 +33,11 @@ Method Signature Changes
 Enhancements
 ************
 
+Libraries
+=========
+
+- **Time:** added functions ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()``
path: user_guide_src/source/changelogs/v4.7.0.rst
user: login: paulbalandan
body: ```suggestion
- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()``
```
created_at: 2025-04-21T13:50:47Z
author_association: MEMBER
diff_hunk: @@ -5,11 +5,13 @@
 $time = $time->addHours(12);
 $time = $time->addDays(21);
 $time = $time->addMonths(14);
+$time = $time->addCalendarMonths(2);
path: user_guide_src/source/libraries/time/031.php
user: login: michalsn
body: Most of the methods are pretty self-explanatory, but `addCalendarMonth()` and `subCalendarMonth()` stand out as needing a bit more context. Would you mind explaining what they’re supposed to do (e.g., the rules behind them) and perhaps sharing a use case or example?

created_at: 2025-04-21T14:55:57Z
author_association: MEMBER
diff_hunk: @@ -5,11 +5,13 @@
 $time = $time->addHours(12);
 $time = $time->addDays(21);
 $time = $time->addMonths(14);
+$time = $time->addCalendarMonths(2);
path: user_guide_src/source/libraries/time/031.php
user: login: christianberkman
body: Sure. Would it be best to do this in the same code block or to have small seperate section? I doubt if a single comment line after the method will be enough to cover the use case. 
created_at: 2025-04-21T15:04:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,11 +5,13 @@
 $time = $time->addHours(12);
 $time = $time->addDays(21);
 $time = $time->addMonths(14);
+$time = $time->addCalendarMonths(2);
path: user_guide_src/source/libraries/time/031.php
user: login: michalsn
body: I think a sub-section under "Modifying the Value" makes the most sense - probably better than just a comment line after the method (as you already mentioned).
created_at: 2025-04-21T15:09:04Z
author_association: MEMBER
diff_hunk: @@ -325,6 +325,27 @@ modify the existing Time instance, but will return a new instance.
 
 .. literalinclude:: time/031.php
 
+addCalendarMonths()
+-------------------
+
+subCalendarMonths()
+-------------------
path: user_guide_src/source/libraries/time.rst
user: login: michalsn
body: ```suggestion
addCalendarMonths() / subCalendarMonths()
-----------------------------------------
```
created_at: 2025-04-22T06:10:03Z
author_association: MEMBER

PR comments:
user: login: michalsn
created_at: 2025-04-18T06:08:56Z
body: Sounds useful.

In addition to fixing PHPStan, we also need:
* tests for this new method
* updated user guide
* changelog entry
author_association: MEMBER
user: login: christianberkman
created_at: 2025-04-18T10:23:53Z
body: Happy to write tests/docs/changelog.

I had tried returntype Time brefore but this also returns an error. Functions like addMonth() do not have any return type specified. What is the correct type?

Is there a way to run PHPStan only on one file, or does this break the testing? `composer phpstan:check system/I18n/TimeTrait.php` does not return any errors.
author_association: CONTRIBUTOR
user: login: paulbalandan
created_at: 2025-04-20T19:35:05Z
body: For phpstan, you should run it on the whole codebase, not just the changed file.

For testing, you can just add your example as a data provider then test on them.
> Initial date: 2025-01-31
+---------+------------+-------------------+
| $months | addMonths  | addCalendarMonths |
+---------+------------+-------------------+
| 0       | 2025-01-31 | 2025-01-31        |
| 1       | 2025-03-03 | 2025-02-28        |
| 2       | 2025-03-31 | 2025-03-31        |
| 3       | 2025-05-01 | 2025-04-30        |
| 4       | 2025-05-31 | 2025-05-31        |
| 5       | 2025-07-01 | 2025-06-30        |
| 6       | 2025-07-31 | 2025-07-31        |
| 7       | 2025-08-31 | 2025-08-31        |
| 8       | 2025-10-01 | 2025-09-30        |
| 9       | 2025-10-31 | 2025-10-31        |
| 10      | 2025-12-01 | 2025-11-30        |
| 11      | 2025-12-31 | 2025-12-31        |
| 12      | 2026-01-31 | 2026-01-31        |
| 13      | 2026-03-03 | 2026-02-28        |
+---------+------------+-------------------+
author_association: MEMBER
user: login: christianberkman
created_at: 2025-04-21T07:59:28Z
body: > For phpstan, you should run it on the whole codebase, not just the changed file.
> 
> For testing, you can just add your example as a data provider then test on them.
> 

Thank you, I figured out I need to run on the whole code base. Error should be gone now.

I wrote tests similar to `testCanAddMonths()`  etc. I used a date that with a result in February.

author_association: CONTRIBUTOR
user: login: paulbalandan
created_at: 2025-04-25T14:12:47Z
body: Thank you, @christianberkman 🎉
author_association: MEMBER