diff --git a/CHANGELOG.md b/CHANGELOG.md index 016fe7b..5f7f957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## 8.4.6 - Unreleased + +### Fixed +- Date math on a `Hijri` instance follows the Hijri calendar. Before, it used Gregorian month lengths on the Hijri numbers: from 29 Safar 1445 (`2023-09-15`), `addDay()` gave 2 Rabi' Al-Awwal instead of 1, and `addMonth()` gave 1 Rabi' Al-Akher instead of 29 Rabi' Al-Awwal. Now days and smaller units move the real date, and months, quarters, years, decades and centuries are Hijri ones (`addMonths()`, `subYears()`, `add()`, `sub()`, `CarbonInterval`, `DateInterval`, `CarbonPeriod`), with Carbon's month overflow settings. `setDate()`, `setDateTime()`, `year()`, `month()`, `day()` and the `year`, `month` and `day` properties set Hijri values. `startOf`/`endOf` month, quarter, year, decade, century and millennium, `firstOfMonth()`, `lastOfMonth()` and `nthOfMonth()` (and their quarter and year versions) use Hijri boundaries. `daysInMonth`, `dayOfYear`, `daysInYear`, `isLeapYear()`, `quarter`, `diffInMonths()`, `diffInYears()`, `age`, `isSameMonth()`, `isSameYear()`, `isSameQuarter()` and `isBirthday()` are Hijri ones. +- Comparing a `Hijri` instance with other dates is right. The instance used to store the Hijri date as a Gregorian one (year 1445 AD), so `getTimestamp()`, `eq()`, `lt()`, `between()`, `isPast()`, `diffInDays()` and `diffForHumans()` were about 579 years off: `Hijri::fromGregorian('2024-03-11')->diffInDays(Carbon::parse('2024-03-11'))` returned `211302`. It now holds the real date and time, and reads it in the Hijri calendar with the adjustment it was converted with. +- `Hijri::now()`, `Hijri::create()`, `Hijri::createFromFormat()` and other Carbon factories return Hijri dates with Hijri month names. They take Gregorian input, like `Hijri::parse()`. +- `Hijri::fromGregorian()` and `toHijri()` keep the exact moment of a `DateTimeInterface`, and a `Hijri` instance passed to them is no longer converted twice. +- `serialize()` keeps the date, timezone and adjustment of a `Hijri` instance. + +### Changed +- `getTimestamp()`, `rawFormat()`, `toISOString()`, `toJSON()`, `json_encode()` and the ATOM, RFC and W3C strings give the Gregorian date. `format()`, `isoFormat()`, `toDateString()`, `toDateTimeString()`, `toFormattedDateString()`, `toDayDateTimeString()`, `toArray()` and `(string)` still print the Hijri date. Pass `toGregorian()` to code that reads the date with `format()`. +- `toGregorian()` returns the date of the instance, also after date math. It no longer converts the changed values as a Hijri date, and no longer throws `InvalidHijriDateException`. `toImmutable()` returns the Gregorian date as a `CarbonImmutable`. +- Removed internal members of `Hijri`: the `$CURRENT_DAY`, `$hijriYear`, `$hijriMonth`, `$hijriDay`, `$hijriConvertedAt` and `$gregorianInstant` properties, `isUnchangedHijri()`, and the `getTranslatedDayName()` override. + ## 8.4.5 - 2026-10-07 ### Added diff --git a/docs/landing/02-features.md b/docs/landing/02-features.md index dc3fbe3..22d74fa 100644 --- a/docs/landing/02-features.md +++ b/docs/landing/02-features.md @@ -11,6 +11,9 @@ items: - icon: switch title: Hijri to Gregorian text: Build a Gregorian Carbon date from Hijri parts with `Carbon::fromHijri(1445, 9, 1)`. + - icon: calendar + title: Hijri Date Math + text: "`addMonths()`, `startOfMonth()` and `diffInMonths()` follow Hijri months, and comparisons work with any date." - icon: document title: Hijri String Parsing text: Parse `YYYY-MM-DD` Hijri strings with an optional time using `Carbon::parseHijri()`. diff --git a/docs/landing/04-faq.md b/docs/landing/04-faq.md index 5940de1..815d73f 100644 --- a/docs/landing/04-faq.md +++ b/docs/landing/04-faq.md @@ -26,6 +26,10 @@ Yes. {package.name} is open source under the {package.license} license, so you c It uses the tabular (arithmetic) Islamic calendar, the same 30-year cycle used by most software. Real-world dates can differ by a day or two from local moon sighting, so the package lets you shift results with a day adjustment. +## Can I do date math in the Hijri calendar? + +Yes. A `Hijri` instance holds the real date, so it compares with any other date, and `addMonths()`, `addYears()`, `startOfMonth()`, `endOfYear()` and `diffInMonths()` follow Hijri month lengths. Call `toGregorian()` to get the Gregorian date back. + ## How do I show month names in Arabic? Set an Arabic locale, either globally with `Carbon::setLocale('ar')` or per date with `->locale('ar')`. Any `ar` locale uses the Arabic month names; every other locale uses the English transliteration. diff --git a/docs/sections/01-overview.md b/docs/sections/01-overview.md index e461977..ff4a703 100644 --- a/docs/sections/01-overview.md +++ b/docs/sections/01-overview.md @@ -15,10 +15,13 @@ Hijri (Islamic) calendar support for PHP, built on [Carbon](https://carbon.nesbo ### Hijri to Gregorian {icon="switch"} `Carbon::fromHijri()` and `Carbon::parseHijri()` return regular Carbon dates. +### Hijri Date Math {icon="calendar"} +`addMonths()`, `startOfMonth()`, `diffInMonths()` and friends follow Hijri months and years. + ### Localized Months {icon="translate"} Arabic month names for `ar` locales, English transliteration for the rest. ::: :::info Quick Tip -The `Hijri` class extends `Carbon\Carbon`, so `format()`, `isoFormat()`, `locale()` and the `year`/`month`/`day` properties all work on a converted date. +The `Hijri` class extends `Carbon\Carbon` and holds the real date, so it compares with any other date, and `format()`, `isoFormat()`, the `year`/`month`/`day` properties and date math all work in Hijri. ::: diff --git a/docs/sections/04-basic-usage.md b/docs/sections/04-basic-usage.md index 1815ee7..44f2dee 100644 --- a/docs/sections/04-basic-usage.md +++ b/docs/sections/04-basic-usage.md @@ -22,6 +22,15 @@ $hijri->monthName; // "Ramadan" $hijri->dayName; // "Monday" ``` +Date math follows the Hijri calendar, and the instance still compares with any other date: + +```php +$hijri->copy()->addMonth()->format('Y-m-d'); // "1445-10-01" +$hijri->copy()->endOfMonth()->format('Y-m-d'); // "1445-09-30" +$hijri->diffInDays(Carbon::parse('2024-03-21 09:30')); // 10 +$hijri->toGregorian()->toDateTimeString(); // "2024-03-11 09:30:00" +``` + Go the other way with `fromHijri()`, which returns a regular Gregorian `Carbon` instance: ```php diff --git a/docs/sections/06-hijri-to-gregorian.md b/docs/sections/06-hijri-to-gregorian.md index f984773..fb1324d 100644 --- a/docs/sections/06-hijri-to-gregorian.md +++ b/docs/sections/06-hijri-to-gregorian.md @@ -31,7 +31,7 @@ Both methods are also available statically on the `Hijri` class without the mixi ### From a Hijri Instance -`toGregorian()` turns a `Hijri` instance back into a Gregorian `Carbon\Carbon`. It returns the exact date, time and timezone the instance was converted from, and remembers the adjustment used for that conversion: +`toGregorian()` turns a `Hijri` instance back into a Gregorian `Carbon\Carbon` with the same date, time and timezone. The instance keeps the adjustment it was converted with, so the result is the original date: ```php title="index.php" use Pharaonic\Hijri\Hijri; @@ -42,9 +42,7 @@ $hijri->format('Y-m-d'); // "1445-09-03" $hijri->toGregorian()->format('Y-m-d H:i e'); // "2024-03-11 20:15 Asia/Riyadh" ``` -Prefer it over `Hijri::fromHijri($hijri->year, $hijri->month, $hijri->day)`, which uses the global adjustment and drops the time. - -If the instance was changed after the conversion (`addDay()`, `setTime()`...), its current year, month and day are converted as a Hijri date with the same adjustment, and its time and timezone are kept. That throws `InvalidHijriDateException` if the changed date doesn't exist in the Hijri calendar. +This also works after date math on the instance (`addMonth()`, `startOfYear()`...). Prefer it over `Hijri::fromHijri($hijri->year, $hijri->month, $hijri->day)`, which uses the global adjustment and drops the time. ### Invalid Input diff --git a/docs/sections/07-date-math.md b/docs/sections/07-date-math.md new file mode 100644 index 0000000..3bf7265 --- /dev/null +++ b/docs/sections/07-date-math.md @@ -0,0 +1,101 @@ +## Date Math & Comparisons + +A `Hijri` instance holds the real date and time, and reads it in the Hijri calendar. Its timestamp, comparisons and day-based differences work with any other date, and month and year math follows Hijri month lengths. + +### Adding and Subtracting + +Days, weeks, hours and smaller units move the real date. Months, quarters, years, decades and centuries are Hijri ones: + +```php title="index.php" +use Pharaonic\Hijri\Hijri; + +$date = Hijri::fromGregorian('2023-09-15'); // 29 Safar 1445 + +$date->copy()->addDay()->format('Y-m-d'); // "1445-03-01" +$date->copy()->addMonth()->format('Y-m-d'); // "1445-03-29" +$date->copy()->subMonth()->format('Y-m-d'); // "1445-01-29" +$date->copy()->addYear()->format('Y-m-d'); // "1446-02-29" +``` + +`add()`, `sub()`, `CarbonInterval`, `DateInterval` and `CarbonPeriod` work the same way: + +```php +use Carbon\CarbonInterval; + +$date->copy()->add(CarbonInterval::months(2))->format('Y-m-d'); // "1445-04-29" +$date->copy()->add(new DateInterval('P1M2D'))->format('Y-m-d'); // "1445-04-01" +``` + +Like Carbon, a `Hijri` instance is mutable: use `copy()` to keep the original. + +### Month Overflow + +When the day doesn't exist in the target month (the 30th, in a 29-day month), the same Carbon settings apply. By default the date moves into the next month; the `NoOverflow` methods stop at the last day: + +```php +$date = Hijri::fromGregorian('2023-08-17'); // 30 Muharram 1445, Safar has 29 days + +$date->copy()->addMonth()->format('Y-m-d'); // "1445-03-01" +$date->copy()->addMonthNoOverflow()->format('Y-m-d'); // "1445-02-29" +``` + +### Setting Hijri Parts + +`setDate()`, `year()`, `month()`, `day()` and the `year`, `month` and `day` properties take Hijri values and keep the time: + +```php +$date = Hijri::fromGregorian('2024-03-11 09:15'); // 1 Ramadan 1445 + +$date->copy()->setDate(1446, 1, 10)->format('Y-m-d H:i'); // "1446-01-10 09:15" +$date->copy()->day(27)->format('Y-m-d'); // "1445-09-27" +$date->copy()->month(12)->day(10)->toGregorian()->toDateString(); // "2024-06-17" +``` + +A day out of range moves the date, like `DateTime::setDate()`: `setDate(1445, 2, 30)` is 1 Rabi' Al-Awwal, as Safar 1445 has 29 days. + +### Start and End of Units + +`startOfMonth()`, `endOfMonth()`, `startOfYear()`, `endOfYear()`, the quarter, decade and century versions, and `firstOfMonth()`, `lastOfMonth()` and `nthOfMonth()` use Hijri months and years: + +```php +use Carbon\Carbon; + +$date = Hijri::fromGregorian('2024-03-21'); // 11 Ramadan 1445 + +$date->copy()->startOfMonth()->toGregorian()->toDateString(); // "2024-03-11" +$date->copy()->endOfYear()->format('Y-m-d H:i:s'); // "1445-12-30 23:59:59" +$date->copy()->lastOfMonth(Carbon::FRIDAY)->format('Y-m-d'); // "1445-09-26" +``` + +### Comparing With Other Dates + +Comparisons use the real date, so a `Hijri` instance can be compared with any Carbon or `DateTime` date: + +```php +$date = Hijri::fromGregorian('2024-03-11'); + +$date->eq(Carbon::parse('2024-03-11')); // true +$date->diffInDays(Carbon::parse('2024-03-21')); // 10 +$date->isPast(); // true +$date->getTimestamp(); // 1710115200 (2024-03-11 00:00 UTC) +``` + +`isSameMonth()`, `isSameYear()`, `isSameQuarter()`, `isCurrentMonth()` and `isBirthday()` compare Hijri months and years. `diffInMonths()`, `diffInQuarters()`, `diffInYears()` and `age` count Hijri months and years: + +```php +$date->isSameMonth(Carbon::parse('2024-04-09')); // true, 30 Ramadan 1445 +$date->diffInMonths(Carbon::parse('2024-04-10')); // 1, 1 Shawwal 1445 +Hijri::fromGregorian('1990-01-01')->age; // age in Hijri years +``` + +`diffForHumans()` describes the real time span, with Carbon's (Gregorian) months and years. + +### What Stays Gregorian + +- `create()`, `createFromDate()`, `createFromFormat()` and `parse()` take Gregorian input, like `fromGregorian()`. +- `modify()` with a relative string (`'+1 month'`) uses PHP's Gregorian rules. Use `addMonths()` instead. +- `rawFormat()`, `toISOString()`, `toJSON()`, `json_encode()` and the RFC/ATOM strings give the Gregorian date, so APIs and other systems receive a standard date. +- `getTimestamp()` and the `U` format character are the real timestamp. +- ISO week values (`W`, `o`, `isoWeek`) are Gregorian ISO weeks. + +To hand the date to code that expects a Gregorian date, use `toGregorian()` (or `toImmutable()` for a `CarbonImmutable`). diff --git a/docs/sections/07-formatting.md b/docs/sections/08-formatting.md similarity index 78% rename from docs/sections/07-formatting.md rename to docs/sections/08-formatting.md index 4d95605..487b478 100644 --- a/docs/sections/07-formatting.md +++ b/docs/sections/08-formatting.md @@ -63,14 +63,24 @@ $hijri->format('D, M j'); // "Mon, Ramadan 1" | `Y`, `y` | Hijri year, four and two digits | | `F`, `M` | Localized Hijri month name (both print the full name) | | `l`, `D` | Localized weekday name, full and short | -| `w`, `N` | Weekday number of the original date (`0`–`6` from Sunday, `1`–`7` from Monday) | +| `w`, `N` | Weekday number (`0`–`6` from Sunday, `1`–`7` from Monday) | | `t` | Number of days in the Hijri month (29 or 30) | | `z` | Day of the Hijri year, starting from `0` | | `L` | `1` if the Hijri year has 355 days, `0` otherwise | | `S` | English ordinal suffix of the Hijri day (`st`, `nd`, `rd`, `th`) | | `c`, `r` | ISO 8601 and RFC 2822 dates built from the Hijri values | -Time and timezone characters (`H`, `i`, `s`, `A`, `e`, `P`...) are unchanged. Escape a character with a backslash to print it as is: `format('\Y Y')` prints `"Y 1445"`. +Time and timezone characters (`H`, `i`, `s`, `A`, `e`, `P`...), the timestamp (`U`) and ISO weeks (`W`, `o`) are unchanged. Escape a character with a backslash to print it as is: `format('\Y Y')` prints `"Y 1445"`. + +`rawFormat()` formats the Gregorian date. `toDateString()`, `toDateTimeString()`, `toFormattedDateString()` and `(string)` print the Hijri date, while `toISOString()`, `toJSON()` and `json_encode()` give the Gregorian ISO-8601 date, so APIs return a standard date: + +```php +$hijri = Carbon::parse('2024-03-11 09:30', 'UTC')->toHijri(); + +$hijri->toDateString(); // "1445-09-01" +$hijri->rawFormat('Y-m-d'); // "2024-03-11" +json_encode($hijri); // "\"2024-03-11T09:30:00.000000Z\"" +``` :::info Tip Prefer `isoFormat()` for non-English output. It reads every name and pattern (`LL`, `LLLL`) from the locale's translations. diff --git a/docs/sections/08-hijri-calendar.md b/docs/sections/09-hijri-calendar.md similarity index 100% rename from docs/sections/08-hijri-calendar.md rename to docs/sections/09-hijri-calendar.md diff --git a/docs/sections/09-api-reference.md b/docs/sections/10-api-reference.md similarity index 56% rename from docs/sections/09-api-reference.md rename to docs/sections/10-api-reference.md index 68f5207..806adb8 100644 --- a/docs/sections/09-api-reference.md +++ b/docs/sections/10-api-reference.md @@ -20,11 +20,19 @@ Added to `Carbon\Carbon` by `Carbon::mixin(HijriCarbon::class)`, and available o | --- | --- | --- | | `Hijri::parse($time = null, $tz = null)` | Convert any Carbon-supported input to Hijri, using the global adjustment. | `Hijri` | | `Hijri::fromGregorian($time = null, $tz = null, ?int $adjustment = null)` | Same as `parse()`, with a per-call adjustment. | `Hijri` | -| `toGregorian()` | The Gregorian date the instance was converted from, with its time, timezone and adjustment. See [Hijri to Gregorian](#hijri-to-gregorian). | `Carbon` | +| `toGregorian()` | The same date, time and timezone as a Gregorian date. See [Hijri to Gregorian](#hijri-to-gregorian). | `Carbon` | +| `toImmutable()` | The same, as a `CarbonImmutable`. | `CarbonImmutable` | +| `setDate($year, $month, $day)` | Set the Hijri date, keeping the time. A day or month out of range moves the date. | `Hijri` | +| `addMonths()`, `addYears()`, `add()`, `sub()`... | Months, quarters, years, decades and centuries are Hijri ones; days and smaller units move the real date. See [Date Math & Comparisons](#date-math). | `Hijri` | +| `startOfMonth()`, `endOfYear()`, `firstOfMonth()`, `nthOfMonth()`... | Hijri month, quarter, year, decade and century boundaries. | `Hijri` | +| `diffInMonths()`, `diffInYears()` | Difference in Hijri months and years (`int` with Carbon 2, `float` with Carbon 3). | `int` \| `float` | +| `isSameMonth()`, `isSameYear()`, `isBirthday()` | Compare Hijri months, years, and month and day. | `bool` | | `Hijri::getInstance()` | The shared instance used to read and set the global adjustment. | `Hijri` | | `locale(?string $locale = null, ...$fallbackLocales)` | Set the locale and switch month names between Arabic and English. Returns the locale when called with no argument. | `Hijri` \| `string` | | `format($format)` | Carbon's `format()` with Hijri date values and localized Hijri month and day names. See [Formatting & Locales](#formatting). | `string` | -| `getTranslatedDayName($context = null, $keySuffix = '', $defaultValue = null)` | Localized weekday name of the original date. | `string` | +| `rawFormat($format)` | PHP's `format()` of the Gregorian date. | `string` | +| `toDateString()`, `toDateTimeString()`, `(string)` | The Hijri date (and time). | `string` | +| `toISOString()`, `toJSON()`, `json_encode()` | The Gregorian ISO-8601 date, for APIs. | `string` | ### Hijri Properties @@ -37,11 +45,16 @@ The usual Carbon properties, read with Hijri values: | `day` | Hijri day of month | `1` | | `monthName` | Localized Hijri month name | `"Ramadan"` | | `dayName` | Localized weekday name | `"Monday"` | -| `dayOfWeek` | Weekday of the original date, `0` (Sunday) to `6` (Saturday) | `1` | -| `dayOfWeekIso` | Weekday of the original date, `1` (Monday) to `7` (Sunday) | `1` | -| `hour`, `minute`, `second` | Unchanged from the source date | `9` | +| `daysInMonth` | Days in the Hijri month (29 or 30) | `30` | +| `dayOfYear` | Day of the Hijri year, from `1` | `237` | +| `daysInYear` | Days in the Hijri year (354 or 355) | `355` | +| `quarter`, `decade`, `century` | Built from the Hijri month and year | `3` | +| `dayOfWeek` | Weekday, `0` (Sunday) to `6` (Saturday) | `1` | +| `dayOfWeekIso` | Weekday, `1` (Monday) to `7` (Sunday) | `1` | +| `hour`, `minute`, `second` | The time of the date | `9` | +| `timestamp` | The Unix timestamp of the date | `1710115200` | -Weekday checks such as `isMonday()`, `isWeekday()` and `isWeekend()` follow the original date too. +Setting `year`, `month` or `day` sets the Hijri value. Weekday checks such as `isMonday()`, `isWeekday()` and `isWeekend()` work as in Carbon. ### HijriCalendar @@ -57,4 +70,4 @@ All methods are static on `Pharaonic\Hijri\Calendar\HijriCalendar`. | Class | Thrown by | When | | --- | --- | --- | -| `Pharaonic\Hijri\Exception\InvalidHijriDateException` | `fromHijri()`, `parseHijri()`, `toGregorian()` of a changed instance | The Hijri date doesn't exist, or the string isn't `YYYY-MM-DD[ HH:MM[:SS]]`. | +| `Pharaonic\Hijri\Exception\InvalidHijriDateException` | `fromHijri()`, `parseHijri()` | The Hijri date doesn't exist, or the string isn't `YYYY-MM-DD[ HH:MM[:SS]]`. | diff --git a/docs/sections/10-examples.md b/docs/sections/11-examples.md similarity index 83% rename from docs/sections/10-examples.md rename to docs/sections/11-examples.md index f7906c0..028a66a 100644 --- a/docs/sections/10-examples.md +++ b/docs/sections/11-examples.md @@ -63,7 +63,25 @@ echo ' / '; echo $hijri->isoFormat('D MMMM YYYY'); // "1 Ramadan 1445" ``` -### 5. Dual-Calendar Date Helper +### 5. Monthly Renewals on the Hijri Calendar + +Renew a subscription on the same Hijri day each month. `addMonthsNoOverflow()` stops at the 29th when the next month is shorter, and `toGregorian()` gives the date to store: + +```php title="src/Renewals.php" +use Carbon\Carbon; + +$start = Carbon::parse('2024-03-11')->toHijri(); // 1 Ramadan 1445 + +$renewals = []; + +for ($month = 1; $month <= 3; $month++) { + $renewals[] = $start->copy()->addMonthsNoOverflow($month)->toGregorian()->toDateString(); +} + +// ["2024-04-10", "2024-05-09", "2024-06-08"] +``` + +### 6. Dual-Calendar Date Helper A small helper class that formats a stored Gregorian timestamp in both calendars, used from a plain PHP template. The Gregorian side is numeric on purpose (see [Troubleshooting](#troubleshooting)): diff --git a/docs/sections/11-troubleshooting.md b/docs/sections/12-troubleshooting.md similarity index 64% rename from docs/sections/11-troubleshooting.md rename to docs/sections/12-troubleshooting.md index f5b94f4..840c132 100644 --- a/docs/sections/11-troubleshooting.md +++ b/docs/sections/12-troubleshooting.md @@ -24,11 +24,11 @@ **Fix:** call `->locale('ar')` on the `Hijri` instance, or `Carbon::setLocale('ar')` before converting. -### Date math gives strange results on a Hijri instance +### Another library gets the Hijri date instead of the Gregorian one -**Cause:** a `Hijri` instance is a Carbon object whose year, month and day hold Hijri numbers. Carbon still applies Gregorian rules to it, so `addDays()`, `diffInDays()`, `diffForHumans()` and comparisons don't follow the Hijri calendar, and calling `toHijri()` on it converts it a second time. +**Cause:** `format()` (and `toDateString()`, `(string)`...) print the Hijri date. Code that reads a `DateTimeInterface` through `format('Y-m-d')`, such as a `datetime` column cast or `new DateTime($date->format(...))`, receives `"1445-09-01"`. -**Fix:** do all math and comparisons on the Gregorian `Carbon` date, and call `toHijri()` only for display. To start from Hijri values, convert them with `Carbon::fromHijri()` first. +**Fix:** pass `$hijri->toGregorian()` to that code. `toISOString()` and `json_encode()` already give the Gregorian date. :::warning Don't store a `Hijri` instance's `format('Y-m-d')` in a `DATE` column. Store the Gregorian date and convert when you display it. @@ -46,18 +46,8 @@ Don't store a `Hijri` instance's `format('Y-m-d')` in a `DATE` column. Store the **Fix:** normalize the input to `YYYY-MM-DD` first, and check the parts with `HijriCalendar::isValidDate()`. -### Limitations +### `modify('+1 month')` moves a Gregorian month -A `Hijri` instance is meant for display. Its `year`, `month`, `day`, weekday, `format()` and `isoFormat()` give Hijri values, but everything else works on an internal representation and isn't supported: +**Cause:** `modify()` uses PHP's relative formats, which follow the Gregorian calendar. -- date arithmetic (`addDays()`, `addMonths()`, `startOfMonth()`...), -- comparisons (`eq()`, `lt()`, `between()`...) and `diff*()` / `diffForHumans()`, -- `timestamp`, and JSON or array serialization. - -Do the arithmetic on the Gregorian `Carbon` date, then call `toHijri()` on the result: - -```php -Carbon::parse('2024-03-11')->addDays(10)->toHijri(); // 11 Ramadan 1445 -``` - -Once a `Hijri` instance has been changed (by `addDays()`, `setDate()`, `setTimezone()`...), its `year`, `month`, `day` and weekday are no longer the Hijri ones. +**Fix:** use `addMonths()`, `addYears()`, `add()` or `sub()`, which follow Hijri months and years. See [Date Math & Comparisons](#date-math). diff --git a/docs/sidebar.md b/docs/sidebar.md index a007a18..74302a2 100644 --- a/docs/sidebar.md +++ b/docs/sidebar.md @@ -6,6 +6,7 @@ - [Basic Usage](#basic-usage) - [Gregorian to Hijri](#gregorian-to-hijri) - [Hijri to Gregorian](#hijri-to-gregorian) + - [Date Math & Comparisons](#date-math) - [Formatting & Locales](#formatting) - [Hijri Calendar](#hijri-calendar) - API Reference diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 92b2f40..ba6bf63 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,6 @@ includes: - vendor/nesbot/carbon/extension.neon + - tests/phpstan-carbon.php parameters: level: 6 diff --git a/src/Concerns/Carbon2/HijriDifference.php b/src/Concerns/Carbon2/HijriDifference.php new file mode 100644 index 0000000..7a55bb3 --- /dev/null +++ b/src/Concerns/Carbon2/HijriDifference.php @@ -0,0 +1,46 @@ +resolveCarbon($date)->copy()->setTimezone($this->getTimezone()); + + [$yearStart, $monthStart, $dayStart] = explode('-', $this->format('Y-m-dHisu')); + [$yearEnd, $monthEnd, $dayEnd] = explode('-', $date->format('Y-m-dHisu')); + + $diff = (((int) $yearEnd - (int) $yearStart) * 12) + (int) $monthEnd - (int) $monthStart; + + if ($diff > 0) { + $diff -= $dayStart > $dayEnd ? 1 : 0; + } elseif ($diff < 0) { + $diff += $dayStart < $dayEnd ? 1 : 0; + } + + return $absolute ? abs($diff) : $diff; + } + + /** + * Get the difference in whole Hijri years. + * + * @param mixed $date + * @param bool $absolute + * @return int + */ + public function diffInYears($date = null, $absolute = true) + { + return intdiv($this->diffInMonths($date, $absolute), 12); + } +} diff --git a/src/Concerns/Carbon3/HijriDifference.php b/src/Concerns/Carbon3/HijriDifference.php new file mode 100644 index 0000000..d9451ba --- /dev/null +++ b/src/Concerns/Carbon3/HijriDifference.php @@ -0,0 +1,48 @@ +resolveCarbon($date); + + if ($utc) { + $start = $start->avoidMutation()->utc(); + $end = $end->avoidMutation()->utc(); + } + + $ascending = ($start <= $end); + $sign = $absolute || $ascending ? 1 : -1; + + if (! $ascending) { + [$start, $end] = [$end, $start]; + } + + $years = intdiv((int) $start->diffInMonths($end, true), 12); + $floorEnd = $start->avoidMutation()->addYears($years); + + if ($floorEnd >= $end) { + return $sign * $years; + } + + $ceilEnd = $start->avoidMutation()->addYears($years + 1); + $daysToFloor = $floorEnd->diffInDays($end); + $daysToCeil = $end->diffInDays($ceilEnd); + + return $sign * ($years + $daysToFloor / ($daysToCeil + $daysToFloor)); + } +} diff --git a/src/Concerns/HijriDifference.php b/src/Concerns/HijriDifference.php new file mode 100644 index 0000000..9dda070 --- /dev/null +++ b/src/Concerns/HijriDifference.php @@ -0,0 +1,13 @@ +hasReturnType()) { + require __DIR__ . '/Carbon3/HijriDifference.php'; +} else { + require __DIR__ . '/Carbon2/HijriDifference.php'; +} diff --git a/src/Hijri.php b/src/Hijri.php index 50338d5..dfd6604 100644 --- a/src/Hijri.php +++ b/src/Hijri.php @@ -2,18 +2,32 @@ namespace Pharaonic\Hijri; -use Carbon\Carbon; use Carbon\AbstractTranslator; +use Carbon\Carbon; +use Carbon\CarbonConverterInterface; +use Carbon\CarbonImmutable; use Carbon\CarbonInterface; +use Closure; +use DateInterval; use DateTimeInterface; use DateTimeZone; +use Pharaonic\Hijri\Calendar\HijriCalendar; +use Pharaonic\Hijri\Concerns\HijriDifference; use Pharaonic\Hijri\Converter\GregorianToHijriConverter; -use Pharaonic\Hijri\Exception\InvalidHijriDateException; +use Pharaonic\Hijri\Converter\HijriToGregorianConverter; use Pharaonic\Hijri\Support\HijriAdjustment; +/** + * A Carbon date read in the Hijri calendar. + * + * The instance holds the real (Gregorian) date and time, so timestamps, + * comparisons and differences work with any other date. The year, month and + * day are read, set and changed in the Hijri calendar. + */ class Hijri extends Carbon { use HijriCarbon; + use HijriDifference; /** * Hijri Months List (Arabic). @@ -21,7 +35,7 @@ class Hijri extends Carbon * @var array */ protected static $HIJRI_MONTHS = [ - 'مُحرَّم', + 'مُحرَّم', 'صفَر', 'ربيع الأول', 'ربيع الآخر', @@ -68,40 +82,21 @@ class Hijri extends Carbon */ protected static $HIJRI_TRANSLATORS = []; - /** @var int|null */ - protected $CURRENT_DAY = null; - - /** @var int|null */ - protected $hijriYear = null; - - /** @var int|null */ - protected $hijriMonth = null; - - /** @var int|null */ - protected $hijriDay = null; - /** - * The underlying date and timezone right after the conversion, used to - * detect whether the instance has been changed since. + * The adjustment of the instance in days, or null to follow the global + * adjustment. * - * @var string|null + * @var int|null */ - protected $hijriConvertedAt = null; + protected $hijriAdjustment = null; /** - * The Gregorian instant the instance was converted from, as a Unix - * timestamp with microseconds ("U.u"). + * The last converted Hijri date, keyed by the Gregorian date and the + * adjustment it was converted from. * - * @var string|null + * @var array{key: string, year: int, month: int, day: int}|null */ - protected $gregorianInstant = null; - - /** - * The adjustment used for the conversion, in days. - * - * @var int|null - */ - protected $hijriAdjustment = null; + private $hijriDate = null; /** * Get the shared Hijri entry-point used by the legacy API. @@ -112,7 +107,8 @@ public static function getInstance(): Hijri } /** - * Prepare a Hijri instance from a Gregorian Carbon-compatible date. + * Prepare a Hijri instance with the given adjustment (the global one by + * default) and the current locale. * * @template T of Hijri * @param T $obj @@ -120,180 +116,723 @@ public static function getInstance(): Hijri */ public function prepare(Hijri $obj, ?int $adjustment = null): Hijri { - $obj->hijriConvertedAt = null; + $obj->hijriAdjustment = $adjustment ?? HijriAdjustment::get(); $obj->locale(static::getLocale()); - $obj->CURRENT_DAY = $obj->dayOfWeek; - return $obj->convertToHijri($adjustment ?? HijriAdjustment::get()); + return $obj; + } + + /** + * Convert a Gregorian Carbon-supported input to Hijri using an optional + * per-call adjustment without changing the global adjustment. + * + * A DateTimeInterface keeps its exact instant and timezone (unless $tz is + * given, in which case the same instant is moved to $tz). + * + * @param string|DateTimeInterface|null $time + * @param DateTimeZone|string|null $tz + */ + public static function fromGregorian($time = null, $tz = null, ?int $adjustment = null): static + { + if ($time instanceof DateTimeInterface) { + /** @var static $parsed */ + $parsed = static::createFromFormat('U.u', $time->format('U.u')); + $parsed->setTimezone($tz ?? $time->getTimezone()); + } else { + /** @var static $parsed */ + $parsed = parent::parse($time, $tz); + } + + return self::getInstance()->prepare($parsed, $adjustment); } /** - * Convert the current Gregorian values to Hijri values. + * Create a Hijri instance from a Carbon-supported input. + * + * @param string|DateTimeInterface|null $time + * @param DateTimeZone|string|null $tz */ - private function convertToHijri(int $adjustment): static + public static function parse($time = null, $tz = null): static { - $this->gregorianInstant = sprintf('%d.%06d', $this->getTimestamp(), $this->micro); - $this->hijriAdjustment = $adjustment; + return self::fromGregorian($time, $tz); + } - $components = (new GregorianToHijriConverter())->convert( - $this, - $adjustment + /** + * Get the Gregorian date of the instance: the same date, time and + * timezone, as a Carbon instance. + */ + public function toGregorian(): Carbon + { + /** @var Carbon $gregorian */ + $gregorian = Carbon::createFromFormat( + 'U.u', + sprintf('%d.%06d', $this->getTimestamp(), $this->micro) ); - $this->hijriYear = $components['year']; - $this->hijriMonth = $components['month']; - $this->hijriDay = $components['day']; - - // The Hijri values are also stored as a Gregorian date for Carbon. A - // day that doesn't exist in that Gregorian month (29 Safar of a common - // year) overflows, so the values above stay the source of truth. - $this->setDate( - $components['year'], - $components['month'], - $components['day'] + return $gregorian->setTimezone($this->getTimezone()); + } + + /** + * Get the Gregorian date of the instance as a CarbonImmutable. + */ + public function toImmutable(): CarbonImmutable + { + return CarbonImmutable::instance($this->toGregorian()); + } + + /** + * Get the Hijri year, month and day of the instance, in its timezone. + * + * @return array{year: int, month: int, day: int} + */ + protected function getHijriDate(): array + { + $adjustment = $this->getConversionAdjustment(); + $key = $this->rawFormat('Y-m-d') . '|' . $adjustment; + + if ($this->hijriDate === null || $this->hijriDate['key'] !== $key) { + $this->hijriDate = ['key' => $key] + (new GregorianToHijriConverter())->convert($this, $adjustment); + } + + return [ + 'year' => $this->hijriDate['year'], + 'month' => $this->hijriDate['month'], + 'day' => $this->hijriDate['day'], + ]; + } + + /** + * Get the adjustment the instance is converted with. + */ + protected function getConversionAdjustment(): int + { + return $this->hijriAdjustment ?? HijriAdjustment::get(); + } + + /** + * Get a part of the instance. + * + * The year, month, day and the values built from them (dayOfYear, + * daysInMonth, quarter...) are Hijri ones. + * + * @param mixed $name + */ + public function get($name): mixed + { + $name = self::enumValue($name); + + switch ($name) { + case 'year': + case 'month': + case 'day': + return $this->getHijriDate()[$name]; + case 'daysInMonth': + $date = $this->getHijriDate(); + + return HijriCalendar::daysInMonth($date['year'], $date['month']); + case 'dayOfYear': + return (int) $this->getHijriFormatValue('z') + 1; + case 'daysInYear': + return $this->isLeapYear() ? 355 : 354; + case 'englishMonth': + case 'shortEnglishMonth': + return self::$TRANS_HIJRI_MONTHS[$this->getHijriDate()['month'] - 1]; + } + + return parent::get($name); + } + + /** + * Set a part of the instance. The year, month and day are Hijri ones. + * + * @param mixed $name + * @param mixed $value + */ + public function set($name, $value = null): static + { + if (is_array($name)) { + foreach ($name as $key => $part) { + $this->set($key, $part); + } + + return $this; + } + + $unit = self::enumValue($name); + + if (in_array($unit, ['year', 'month', 'day'], true)) { + $date = $this->getHijriDate(); + $date[$unit] = (int) self::enumValue($value); + $this->setDate($date['year'], $date['month'], $date['day']); + + return $this; + } + + return parent::set($name, $value); + } + + /** + * Set the Hijri date, keeping the time. + * + * Like DateTime::setDate(), a month or day out of range moves the date + * forward or backward: setDate(1445, 2, 30) is the 1st of Rabi' Al-Awwal + * 1445, as Safar 1445 has 29 days. + * + * @param int $year + * @param int $month + * @param int $day + */ + public function setDate($year, $month, $day): static + { + $months = ((int) $year * 12) + (int) $month - 1; + $year = (int) floor($months / 12); + $month = $months - ($year * 12) + 1; + + $first = (new HijriToGregorianConverter())->convert( + $year, + $month, + 1, + $this->getConversionAdjustment() ); - $this->hijriConvertedAt = $this->getHijriState(); + parent::setDate($first['year'], $first['month'], $first['day'] + (int) $day - 1); return $this; } /** - * Get the Gregorian date of the instance. + * Add an amount of a unit. Months, quarters, years, decades, centuries + * and millennia are Hijri ones; other units are passed to Carbon. * - * While the instance is unchanged since its conversion, this is the exact - * date and time it was converted from, in its timezone. Once it's changed, - * its current year, month and day are converted as a Hijri date, using - * the adjustment of the original conversion, and its time and timezone - * are kept. An instance that was never converted (Hijri::now(), - * Hijri::create()...) already holds a Gregorian date and is returned as is. + * @param mixed $unit + * @param int|float $value + * @param mixed $overflow + * @param int|null $anchorDay + */ + public function addUnit($unit, $value = 1, $overflow = null, $anchorDay = null): static + { + $units = [ + 'month' => [1, 'month'], + 'quarter' => [static::MONTHS_PER_QUARTER, 'month'], + 'year' => [1, 'year'], + 'decade' => [static::YEARS_PER_DECADE, 'year'], + 'century' => [static::YEARS_PER_CENTURY, 'year'], + 'millennium' => [static::YEARS_PER_MILLENNIUM, 'year'], + ]; + $name = static::singularUnit((string) self::enumValue($unit)); + + if (! isset($units[$name]) || ! is_numeric($value)) { + return parent::addUnit(...func_get_args()); + } + + [$factor, $baseUnit] = $units[$name]; + + // Like Carbon, only whole months or years are added. + $months = (int) ($value * $factor) * ($baseUnit === 'year' ? 12 : 1); + $mode = is_object($overflow) ? $overflow->name : $overflow; + + if ($anchorDay !== null || $mode === 'AnchorDay') { + $this->addHijriMonths($months, false, $anchorDay ?? $this->getHijriDate()['day']); + } else { + $this->addHijriMonths($months, $this->shouldOverflowHijri($baseUnit, $mode), null); + } + + return $this; + } + + /** + * Add a DateInterval. Its years and months are Hijri ones. + */ + public function rawAdd(DateInterval $interval): static + { + return $this->addHijriInterval($interval, 1); + } + + /** + * Subtract a DateInterval. Its years and months are Hijri ones. + */ + public function rawSub(DateInterval $interval): static + { + return $this->addHijriInterval($interval, -1); + } + + /** + * Add an interval, or an amount of a unit. * - * @throws InvalidHijriDateException if the changed date isn't a valid Hijri date + * @param mixed $unit + * @param mixed $value + * @param mixed $overflow + * @param int|null $anchorDay */ - public function toGregorian(): Carbon + public function add($unit, $value = 1, $overflow = null, $anchorDay = null): static { - if ($this->hijriConvertedAt === null) { - return $this->toGregorianCarbon(sprintf('%d.%06d', $this->getTimestamp(), $this->micro)); + if ($unit instanceof DateInterval && ! $unit instanceof CarbonConverterInterface) { + return $this->rawAdd($unit); } - if ($this->isUnchangedHijri()) { - return $this->toGregorianCarbon((string) $this->gregorianInstant); + return parent::add(...func_get_args()); + } + + /** + * Subtract an interval, or an amount of a unit. + * + * @param mixed $unit + * @param mixed $value + * @param mixed $overflow + * @param int|null $anchorDay + */ + public function sub($unit, $value = 1, $overflow = null, $anchorDay = null): static + { + if ($unit instanceof DateInterval && ! $unit instanceof CarbonConverterInterface) { + return $this->rawSub($unit); } - return static::fromHijri( - (int) $this->year, - (int) $this->month, - (int) $this->day, - $this->getTimezone(), - $this->hijriAdjustment - )->setTime($this->hour, $this->minute, $this->second, $this->micro); + return parent::sub(...func_get_args()); } /** - * Build a Carbon instance from a "U.u" instant, in the timezone of the - * instance. + * Add Hijri months, keeping the day when it exists in the target month. */ - private function toGregorianCarbon(string $instant): Carbon + private function addHijriMonths(int $months, bool $overflow, ?int $anchorDay): void { - /** @var Carbon $gregorian */ - $gregorian = Carbon::createFromFormat('U.u', $instant); + $date = $this->getHijriDate(); + $months += ($date['year'] * 12) + $date['month'] - 1; + $year = (int) floor($months / 12); + $month = $months - ($year * 12) + 1; + $day = $anchorDay ?? $date['day']; + + if (! $overflow || $anchorDay !== null) { + $day = min($day, HijriCalendar::daysInMonth($year, $month)); + } - return $gregorian->setTimezone($this->getTimezone()); + $this->setDate($year, $month, $day); } /** - * Get the underlying date and timezone, as stored by Carbon. + * Add the years and months of an interval as Hijri ones, then the rest. */ - private function getHijriState(): string + private function addHijriInterval(DateInterval $interval, int $sign): static { - return $this->rawFormat('Y-m-d H:i:s.u e'); + if ($interval->invert) { + $sign = -$sign; + } + + $months = (($interval->y * 12) + $interval->m) * $sign; + + if ($months !== 0) { + $this->addHijriMonths($months, true, null); + } + + $rest = new DateInterval('PT0S'); + $rest->d = $interval->d; + $rest->h = $interval->h; + $rest->i = $interval->i; + $rest->s = $interval->s; + $rest->f = $interval->f; + $rest->invert = $sign < 0 ? 1 : 0; + + parent::rawAdd($rest); + + return $this; } /** - * Determine if the instance still holds the date it was converted to. + * Determine if adding months or years can overflow into the next month. * - * Once it's changed (addDays(), setDate(), setTimezone()...) the Hijri - * values no longer apply and Carbon's own values are used. + * @param bool|string|null $mode */ - protected function isUnchangedHijri(): bool + private function shouldOverflowHijri(string $unit, $mode): bool { - return $this->hijriConvertedAt !== null - && $this->hijriConvertedAt === $this->getHijriState(); + if ($mode !== null) { + return $mode === true || $mode === 'Overflow'; + } + + $ucUnit = ucfirst($unit) . 's'; + + return $this->{'local' . $ucUnit . 'Overflow'} ?? static::{'shouldOverflow' . $ucUnit}(); } /** - * Get a part of the instance. + * Get the value of a backed enum (Carbon 3's Unit, Month...), or the + * value itself. * - * The year, month, day and weekday are the Hijri ones while the instance - * is unchanged since its conversion. + * @param mixed $value + * @return mixed + */ + private static function enumValue($value) + { + return is_object($value) && property_exists($value, 'value') ? $value->value : $value; + } + + /** + * Go to the end of the Hijri year. + */ + public function endOfYear(): static + { + return $this->setDate($this->year, 12, 1)->endOfMonth(); + } + + /** + * Go to the end of the Hijri decade. + */ + public function endOfDecade(): static + { + $year = $this->year - $this->year % static::YEARS_PER_DECADE + static::YEARS_PER_DECADE - 1; + + return $this->setDate($year, 12, 1)->endOfMonth(); + } + + /** + * Go to the end of the Hijri century. + */ + public function endOfCentury(): static + { + $year = $this->year - 1 - ($this->year - 1) % static::YEARS_PER_CENTURY + static::YEARS_PER_CENTURY; + + return $this->setDate($year, 12, 1)->endOfMonth(); + } + + /** + * Go to the end of the Hijri millennium. + */ + public function endOfMillennium(): static + { + $year = $this->year - 1 - ($this->year - 1) % static::YEARS_PER_MILLENNIUM + static::YEARS_PER_MILLENNIUM; + + return $this->setDate($year, 12, 1)->endOfMonth(); + } + + /** + * Go to the first day of the Hijri month, or to its first given weekday. * - * @param mixed $name + * @param int|null $dayOfWeek */ - public function get($name): mixed + public function firstOfMonth($dayOfWeek = null): static { - if (is_string($name) && $this->isUnchangedHijri()) { - switch ($name) { - case 'year': - return $this->hijriYear; - case 'month': - return $this->hijriMonth; - case 'day': - return $this->hijriDay; - case 'dayOfWeek': - return $this->CURRENT_DAY; - case 'dayOfWeekIso': - return $this->CURRENT_DAY === 0 ? 7 : $this->CURRENT_DAY; - } + $this->startOfDay()->day(1); + + if ($dayOfWeek !== null && $this->dayOfWeek !== (int) $dayOfWeek) { + $this->next((int) $dayOfWeek); } - return parent::get($name); + return $this; } /** - * Get the units used by isoFormat(), reading the day, month and year - * through the Hijri values instead of the stored Gregorian date. + * Go to the last day of the Hijri month, or to its last given weekday. * - * @return array + * @param int|null $dayOfWeek */ - public static function getIsoUnits(): array + public function lastOfMonth($dayOfWeek = null): static { - $units = parent::getIsoUnits(); + $this->startOfDay()->day($this->daysInMonth); - $units['DD'] = ['getPaddedUnit', ['day']]; - $units['MM'] = ['getPaddedUnit', ['month']]; - $units['YY'] = static function (CarbonInterface $date): string { - return sprintf('%02d', $date->year % 100); - }; + if ($dayOfWeek !== null && $this->dayOfWeek !== (int) $dayOfWeek) { + $this->previous((int) $dayOfWeek); + } - return $units; + return $this; } /** - * Convert a Gregorian Carbon-supported input to Hijri using an optional - * per-call adjustment without changing the global adjustment. + * Go to the given occurrence of a weekday in the Hijri month, or return + * false if the month doesn't have it. * - * @param string|DateTimeInterface|null $time - * @param DateTimeZone|string|null $tz + * @param int $nth + * @param int $dayOfWeek + * @return static|false */ - public static function fromGregorian($time = null, $tz = null, ?int $adjustment = null): static + public function nthOfMonth($nth, $dayOfWeek) { - $instance = self::getInstance(); + $date = $this->avoidMutation()->firstOfMonth()->modify('+' . $nth . ' ' . static::$days[$dayOfWeek]); - /** @var static $parsed */ - $parsed = parent::parse($time, $tz); + return $date->year === $this->year && $date->month === $this->month + ? $this->modify($date->rawFormat('Y-m-d H:i:s.u')) + : false; + } - return $instance->prepare($parsed, $adjustment); + /** + * Go to the given occurrence of a weekday in the Hijri quarter, or return + * false if the quarter doesn't have it. + * + * @param int $nth + * @param int $dayOfWeek + * @return static|false + */ + public function nthOfQuarter($nth, $dayOfWeek) + { + $date = $this->avoidMutation()->firstOfQuarter()->modify('+' . $nth . ' ' . static::$days[$dayOfWeek]); + + return $date->year === $this->year && $date->quarter === $this->quarter + ? $this->modify($date->rawFormat('Y-m-d H:i:s.u')) + : false; } /** - * Create a Hijri instance from a Carbon-supported input. + * Go to the given occurrence of a weekday in the Hijri year, or return + * false if the year doesn't have it. * - * @param string|DateTimeInterface|null $time - * @param DateTimeZone|string|null $tz + * @param int $nth + * @param int $dayOfWeek + * @return static|false */ - public static function parse($time = null, $tz = null): static + public function nthOfYear($nth, $dayOfWeek) { - return self::fromGregorian($time, $tz); + $date = $this->avoidMutation()->firstOfYear()->modify('+' . $nth . ' ' . static::$days[$dayOfWeek]); + + return $date->year === $this->year + ? $this->modify($date->rawFormat('Y-m-d H:i:s.u')) + : false; + } + + /** + * Determine if the instance is today. + */ + public function isToday(): bool + { + return $this->rawFormat('Y-m-d') === $this->nowWithSameTz()->rawFormat('Y-m-d'); + } + + /** + * Determine if the instance is yesterday. + */ + public function isYesterday(): bool + { + return $this->rawFormat('Y-m-d') === static::yesterday($this->getTimezone())->rawFormat('Y-m-d'); + } + + /** + * Determine if the instance is tomorrow. + */ + public function isTomorrow(): bool + { + return $this->rawFormat('Y-m-d') === static::tomorrow($this->getTimezone())->rawFormat('Y-m-d'); + } + + /** + * Determine if the given date (today by default) has the same Hijri month + * and day as the instance. + * + * @param DateTimeInterface|string|null $date + */ + public function isBirthday($date = null): bool + { + $other = $this->resolveCarbon($date); + + return $other->month === $this->month && $other->day === $this->day; + } + + /** + * Keep the date and time, and change the timezone (and so the instant). + * + * @param DateTimeZone|string $value + */ + public function shiftTimezone($value): static + { + $dateTime = $this->rawFormat('Y-m-d H:i:s.u'); + + return $this->setTimezone($value)->modify($dateTime); + } + + /** + * Determine if the Hijri year has 355 days. + */ + public function isLeapYear(): bool + { + return HijriCalendar::isLeapYear($this->year); + } + + /** + * Determine if the given date is in the same Hijri unit (year, month, + * quarter...) as the instance. + * + * @param string $unit + * @param DateTimeInterface|string|null $date + */ + public function isSameUnit($unit, $date = null): bool + { + if ($unit === 'year' || $unit === 'month') { + $other = $this->resolveCarbon($date); + + return $other->year === $this->year && ($unit === 'year' || $other->month === $this->month); + } + + return parent::isSameUnit($unit, $date ?? 'now'); + } + + /** + * Determine if the given date is in the same Hijri month as the instance. + * + * @param DateTimeInterface|string|null $date + * @param bool $ofSameYear + */ + public function isSameMonth($date = null, $ofSameYear = true): bool + { + $other = $this->resolveCarbon($date); + + return $other->month === $this->month && (! $ofSameYear || $other->year === $this->year); + } + + /** + * Get a Hijri instance with the same adjustment from a date or a string. + * + * @param DateTimeInterface|string|null $date + */ + protected function resolveCarbon($date = null): self + { + $resolved = parent::resolveCarbon($date); + $adjustment = $this->getConversionAdjustment(); + + if ($resolved instanceof self && $resolved->getConversionAdjustment() === $adjustment) { + return $resolved; + } + + return static::fromGregorian($resolved, null, $adjustment); + } + + /** + * Format the instance as a string (Hijri date). + */ + public function __toString(): string + { + /** @var mixed $format */ + $format = $this->localToStringFormat ?? null; + + if ($format instanceof Closure) { + return $format($this); + } + + if (! is_string($format) || $format === '') { + $format = CarbonInterface::DEFAULT_TO_STRING_FORMAT; + } + + return $this->format($format); + } + + /** + * Format the instance as a Hijri date string (Y-m-d). + */ + public function toDateString(): string + { + return $this->format('Y-m-d'); + } + + /** + * Format the instance as a Hijri date and time string. + * + * @param string $unitPrecision + */ + public function toDateTimeString($unitPrecision = 'second'): string + { + return $this->format('Y-m-d ' . static::getTimeFormatByPrecision($unitPrecision)); + } + + /** + * Format the instance as a readable Hijri date (M j, Y). + */ + public function toFormattedDateString(): string + { + return $this->format('M j, Y'); + } + + /** + * Format the instance as a readable Hijri date with the weekday. + */ + public function toFormattedDayDateString(): string + { + return $this->format('D, M j, Y'); + } + + /** + * Format the instance as a readable Hijri date and time with the weekday. + */ + public function toDayDateTimeString(): string + { + return $this->format('D, M j, Y g:i A'); + } + + /** + * Get the instance as an array of Hijri values. + * + * @return array + */ + public function toArray(): array + { + $array = parent::toArray(); + $array['formatted'] = $this->format(CarbonInterface::DEFAULT_TO_STRING_FORMAT); + + return $array; + } + + /** + * Get the Gregorian ISO-8601 string of the instance, as used for JSON. + * + * @param bool $keepOffset + */ + public function toISOString($keepOffset = false): ?string + { + return $this->toGregorian()->toISOString($keepOffset); + } + + /** + * @return array + */ + public function __serialize(): array + { + // Older Carbon 2 releases serialize with __sleep() only. + $data = method_exists(Carbon::class, '__serialize') ? parent::__serialize() : [ + 'timezone' => $this->getTimezone()->getName(), + ]; + $data['date'] = $this->rawFormat('Y-m-d H:i:s.u'); + + if (! isset($data['dumpLocale']) && $this->hasLocalTranslator()) { + $data['dumpLocale'] = $this->locale(); + } + + if (isset($data['dumpDateProperties']['date'])) { + $data['dumpDateProperties']['date'] = $data['date']; + } + + $data['hijriAdjustment'] = $this->hijriAdjustment; + + return $data; + } + + /** + * @param array $data + */ + public function __unserialize(array $data): void + { + if (method_exists(Carbon::class, '__unserialize')) { + parent::__unserialize($data); + } else { + $this->__construct($data['date'], $data['timezone']); + + if (isset($data['dumpLocale'])) { + $this->locale($data['dumpLocale']); + } + } + + $this->hijriAdjustment = $data['hijriAdjustment'] ?? null; + } + + /** + * Get the units used by isoFormat(), reading the day, month and year + * through the Hijri values. + * + * @return array + */ + public static function getIsoUnits(): array + { + $units = parent::getIsoUnits(); + + $units['DD'] = ['getPaddedUnit', ['day']]; + $units['MM'] = ['getPaddedUnit', ['month']]; + $units['YY'] = static function (CarbonInterface $date): string { + return sprintf('%02d', $date->year % 100); + }; + + return $units; } /** @@ -318,6 +857,29 @@ public function locale(?string $locale = null, ...$fallbackLocales): static|stri return $this; } + /** + * Get a translation message, with the Hijri month names. + * + * @param mixed $translator + * @return mixed + */ + public function getTranslationMessage( + string $key, + ?string $locale = null, + ?string $default = null, + $translator = null + ) { + if ($translator === null) { + $translator = $this->getLocalTranslator(); + + if ($translator instanceof AbstractTranslator) { + $translator = self::getHijriTranslator($translator); + } + } + + return parent::getTranslationMessage($key, $locale, $default, $translator); + } + /** * Get the dedicated Hijri translator matching the given Carbon translator. * @@ -351,48 +913,19 @@ protected static function getHijriTranslator(AbstractTranslator $translator): Ab return self::$HIJRI_TRANSLATORS[$key]; } - public function getTranslatedDayName($context = null, $keySuffix = '', $defaultValue = null): string - { - return $this->getTranslatedFormByRegExp( - 'weekdays', - $keySuffix, - $context, - $this->CURRENT_DAY, - $defaultValue ?: $this->englishDayOfWeek - ); - } - /** * Format the instance with Hijri values. * - * While the instance is unchanged since its conversion, the date - * characters read the Hijri values: d, j, m, n, Y, y, t (days in the Hijri - * month), z (day of the Hijri year, from 0), L (Hijri leap year), S - * (English suffix of the Hijri day), w and N (original weekday), and c and - * r (built from the Hijri date). l, D, F and M print the localized weekday - * and Hijri month names. Other characters are formatted by Carbon. + * The date characters read the Hijri date: d, j, m, n, Y, y, t (days in + * the Hijri month), z (day of the Hijri year, from 0), L (Hijri leap + * year), S (English suffix of the Hijri day), and c and r (built from the + * Hijri date). l, D, F and M print the localized weekday and Hijri month + * names. Other characters (time, timezone, U, w, N, o, W...) are + * formatted by Carbon. rawFormat() formats the Gregorian date. */ public function format($format): string { - if ($this->isUnchangedHijri()) { - return parent::format($this->toHijriFormat((string) $format)); - } - - return str_replace( - [ - $this->englishDayOfWeek, - $this->englishMonth, - $this->shortEnglishDayOfWeek, - $this->shortEnglishMonth, - ], - [ - $this->dayName, - $this->monthName, - $this->shortDayName, - $this->monthName, - ], - parent::format($format) - ); + return parent::format($this->toHijriFormat((string) $format)); } /** @@ -438,10 +971,7 @@ private function toHijriFormat(string $format): string */ private function getHijriFormatValue(string $char): ?string { - $year = (int) $this->hijriYear; - $month = (int) $this->hijriMonth; - $day = (int) $this->hijriDay; - $converter = new GregorianToHijriConverter(); + ['year' => $year, 'month' => $month, 'day' => $day] = $this->getHijriDate(); switch ($char) { case 'd': @@ -454,14 +984,10 @@ private function getHijriFormatValue(string $char): ?string $dayOfYear = $day - 1; for ($previous = 1; $previous < $month; $previous++) { - $dayOfYear += $converter->daysInMonth($year, $previous); + $dayOfYear += HijriCalendar::daysInMonth($year, $previous); } return (string) $dayOfYear; - case 'w': - return (string) $this->CURRENT_DAY; - case 'N': - return (string) ($this->CURRENT_DAY === 0 ? 7 : $this->CURRENT_DAY); case 'l': return $this->dayName; case 'D': @@ -474,9 +1000,9 @@ private function getHijriFormatValue(string $char): ?string case 'M': return $this->monthName; case 't': - return (string) $converter->daysInMonth($year, $month); + return (string) HijriCalendar::daysInMonth($year, $month); case 'L': - return $converter->daysInYear($year) === 355 ? '1' : '0'; + return HijriCalendar::isLeapYear($year) ? '1' : '0'; case 'Y': return sprintf('%04d', $year); case 'y': diff --git a/tests/Unit/HijriDateMathTest.php b/tests/Unit/HijriDateMathTest.php new file mode 100644 index 0000000..0b513fa --- /dev/null +++ b/tests/Unit/HijriDateMathTest.php @@ -0,0 +1,444 @@ +setHijriAdjustment(-1); + } + + protected function tearDown(): void + { + Hijri::getInstance()->setHijriAdjustment(-1); + + parent::tearDown(); + } + + public function testTheInstanceHoldsTheGregorianDate(): void + { + $date = Hijri::fromGregorian('2024-03-11 08:30:00', 'UTC'); + + self::assertSame('1445-09-01', $date->format('Y-m-d')); + self::assertSame('2024-03-11 08:30:00', $date->rawFormat('Y-m-d H:i:s')); + self::assertSame(Carbon::parse('2024-03-11 08:30:00', 'UTC')->getTimestamp(), $date->getTimestamp()); + self::assertSame(Carbon::parse('2024-03-11 08:30:00', 'UTC')->getTimestamp(), $date->timestamp); + self::assertSame((string) $date->getTimestamp(), $date->format('U')); + } + + public function testComparisonsWithGregorianDates(): void + { + $date = Hijri::fromGregorian('2024-03-11 12:00', 'UTC'); + + self::assertEquals(0, $date->diffInDays(Carbon::parse('2024-03-11 12:00', 'UTC'))); + self::assertEquals(10, $date->diffInDays(Carbon::parse('2024-03-21 12:00', 'UTC'), true)); + self::assertEquals(36, $date->diffInHours(Carbon::parse('2024-03-13 00:00', 'UTC'), true)); + self::assertTrue($date->eq(Carbon::parse('2024-03-11 12:00', 'UTC'))); + self::assertTrue($date->lt(Carbon::parse('2024-03-12', 'UTC'))); + self::assertTrue($date->gt(Carbon::parse('2024-03-10', 'UTC'))); + self::assertTrue($date->between(Carbon::parse('2024-03-10', 'UTC'), Carbon::parse('2024-03-12', 'UTC'))); + self::assertTrue($date->isPast()); + self::assertFalse($date->isFuture()); + self::assertTrue(Carbon::parse('2024-03-11 12:00', 'UTC')->eq($date)); + self::assertTrue($date->isSameDay(Carbon::parse('2024-03-11', 'UTC'))); + } + + public function testDayMathFollowsTheRealDate(): void + { + $date = Hijri::fromGregorian('2023-09-15'); + + self::assertSame('1445-02-29', $date->format('Y-m-d')); + self::assertSame('1445-03-01', $date->copy()->addDay()->format('Y-m-d')); + self::assertSame('1445-02-28', $date->copy()->subDay()->format('Y-m-d')); + self::assertSame('1445-03-07', $date->copy()->addWeek()->format('Y-m-d')); + self::assertSame('1445-03-01', $date->copy()->addHours(24)->format('Y-m-d')); + } + + public function testEveryDayAddsUpToTheNextDay(): void + { + $day = new DateTimeImmutable('2019-01-01'); + $end = new DateTimeImmutable('2026-12-31'); + $oneDay = new DateInterval('P1D'); + + while ($day <= $end) { + $next = $day->add($oneDay); + + self::assertSame( + Hijri::fromGregorian($next)->format('Y-m-d'), + Hijri::fromGregorian($day)->addDay()->format('Y-m-d'), + $day->format('Y-m-d') + ); + + $day = $next; + } + } + + public function testMonthMathFromTheReport(): void + { + $date = Hijri::fromGregorian('2023-09-15'); + + self::assertSame('1445-03-29', $date->copy()->addMonth()->format('Y-m-d')); + self::assertSame('1445-01-29', $date->copy()->subMonth()->format('Y-m-d')); + self::assertSame('1446-02-29', $date->copy()->addYear()->format('Y-m-d')); + } + + public function testAddingMonthsInTheHijriCalendar(): void + { + $converter = new HijriToGregorianConverter(); + $day = new DateTimeImmutable('2022-01-01 13:00:00'); + $end = new DateTimeImmutable('2025-12-31'); + $fiveDays = new DateInterval('P5D'); + + while ($day <= $end) { + $date = Hijri::fromGregorian($day); + + foreach ([-25, -13, -1, 1, 2, 11, 12, 30] as $months) { + $total = ($date->year * 12) + $date->month - 1 + $months; + $year = intdiv($total, 12); + $month = $total % 12 + 1; + $daysInMonth = HijriCalendar::daysInMonth($year, $month); + $expected = $converter->convert($year, $month, min($date->day, $daysInMonth), -1); + $label = sprintf('%s %+d months', $date->format('Y-m-d'), $months); + + $result = $date->copy()->addMonthsNoOverflow($months); + + self::assertSame( + vsprintf('%04d-%02d-%02d 13:00:00', $expected), + $result->rawFormat('Y-m-d H:i:s'), + $label + ); + self::assertSame([$year, $month], [$result->year, $result->month], $label); + + $overflow = $date->copy()->addMonthsWithOverflow($months); + + if ($date->day <= $daysInMonth) { + self::assertSame($result->rawFormat('Y-m-d'), $overflow->rawFormat('Y-m-d'), $label); + } else { + self::assertSame(1, $overflow->day, $label); + self::assertSame( + $result->copy()->addDay()->rawFormat('Y-m-d'), + $overflow->rawFormat('Y-m-d'), + $label + ); + } + } + + $day = $day->add($fiveDays); + } + } + + public function testOverflowOfTheThirtiethDay(): void + { + // 1445-01-30: Safar 1445 has 29 days. + $date = Hijri::fromGregorian('2023-08-17'); + + self::assertSame('1445-01-30', $date->format('Y-m-d')); + self::assertSame('1445-02-29', $date->copy()->addMonthNoOverflow()->format('Y-m-d')); + self::assertSame('1445-03-01', $date->copy()->addMonthWithOverflow()->format('Y-m-d')); + self::assertSame('1445-02-29', $date->copy()->addMonthsNoOverflow(1)->format('Y-m-d')); + + // 1445-12-30 (leap year) to 1446-12, which has 29 days. + $date = Hijri::fromGregorian('2024-07-07'); + + self::assertSame('1445-12-30', $date->format('Y-m-d')); + self::assertSame('1446-12-29', $date->copy()->addYearNoOverflow()->format('Y-m-d')); + self::assertSame('1447-01-01', $date->copy()->addYearWithOverflow()->format('Y-m-d')); + } + + public function testOtherUnitsAreHijriToo(): void + { + $date = Hijri::fromGregorian('2024-03-11'); + + self::assertSame('1445-12-01', $date->copy()->addQuarter()->format('Y-m-d')); + self::assertSame('1455-09-01', $date->copy()->addDecade()->format('Y-m-d')); + self::assertSame('1545-09-01', $date->copy()->addCentury()->format('Y-m-d')); + self::assertSame('1446-09-01', $date->copy()->add(1, 'year')->format('Y-m-d')); + self::assertSame('1445-10-01', $date->copy()->add('month', 1)->format('Y-m-d')); + self::assertSame('1445-08-01', $date->copy()->sub(1, 'month')->format('Y-m-d')); + } + + public function testIntervalsAreHijri(): void + { + $date = Hijri::fromGregorian('2024-03-11 10:00'); + + self::assertSame('1445-11-01', $date->copy()->add(CarbonInterval::months(2))->format('Y-m-d')); + self::assertSame('1445-10-03 10:00', $date->copy()->add(new DateInterval('P1M2D'))->format('Y-m-d H:i')); + self::assertSame('1444-09-01', $date->copy()->sub(new DateInterval('P1Y'))->format('Y-m-d')); + self::assertSame( + '1446-10-01 12:30', + $date->copy()->add('1 year 1 month 2 hours 30 minutes')->format('Y-m-d H:i') + ); + self::assertSame('1445-08-01', $date->copy()->sub(CarbonInterval::month())->format('Y-m-d')); + + $interval = new DateInterval('P1M'); + $interval->invert = 1; + + self::assertSame('1445-08-01', $date->copy()->add($interval)->format('Y-m-d')); + + $months = []; + + foreach (CarbonPeriod::create($date, '1 month', 3) as $month) { + $months[] = $month->format('Y-m-d'); + } + + self::assertSame(['1445-09-01', '1445-10-01', '1445-11-01'], $months); + } + + public function testSettingHijriParts(): void + { + $date = Hijri::fromGregorian('2024-03-11 09:15'); + + self::assertSame('1446-09-01 09:15', $date->copy()->setYear(1446)->format('Y-m-d H:i')); + self::assertSame('1445-10-01', $date->copy()->month(10)->format('Y-m-d')); + self::assertSame('1445-09-15', $date->copy()->day(15)->format('Y-m-d')); + self::assertSame('1445-09-15', $date->copy()->setDay(15)->format('Y-m-d')); + self::assertSame('1440-02-01', $date->copy()->set(['year' => 1440, 'month' => 2])->format('Y-m-d')); + self::assertSame('1440-02-10 09:15', $date->copy()->setDate(1440, 2, 10)->format('Y-m-d H:i')); + self::assertSame('1440-02-10 07:00', $date->copy()->setDateTime(1440, 2, 10, 7, 0)->format('Y-m-d H:i')); + self::assertSame('1445-01-01', $date->copy()->dayOfYear(1)->format('Y-m-d')); + + $changed = $date->copy(); + $changed->year = 1446; + $changed->month = 1; + $changed->day = 10; + + self::assertSame('1446-01-10', $changed->format('Y-m-d')); + self::assertSame('2024-07-17', $changed->toGregorian()->format('Y-m-d')); + } + + public function testSettingADayOutOfRangeMovesTheDate(): void + { + $date = Hijri::fromGregorian('2024-03-11'); + + // Safar 1445 has 29 days. + self::assertSame('1445-03-01', $date->copy()->setDate(1445, 2, 30)->format('Y-m-d')); + self::assertSame('1445-01-30', $date->copy()->setDate(1445, 2, 0)->format('Y-m-d')); + self::assertSame('1446-01-01', $date->copy()->setDate(1445, 13, 1)->format('Y-m-d')); + self::assertSame('1444-12-01', $date->copy()->setDate(1445, 0, 1)->format('Y-m-d')); + } + + public function testBoundaries(): void + { + $date = Hijri::fromGregorian('2024-03-21 15:00'); + + self::assertSame('1445-09-01 00:00:00', $date->copy()->startOfMonth()->format('Y-m-d H:i:s')); + self::assertSame('1445-09-30 23:59:59', $date->copy()->endOfMonth()->format('Y-m-d H:i:s')); + self::assertSame('1445-07-01', $date->copy()->startOfQuarter()->format('Y-m-d')); + self::assertSame('1445-09-30', $date->copy()->endOfQuarter()->format('Y-m-d')); + self::assertSame('1445-01-01 00:00:00', $date->copy()->startOfYear()->format('Y-m-d H:i:s')); + self::assertSame('1445-12-30 23:59:59', $date->copy()->endOfYear()->format('Y-m-d H:i:s')); + self::assertSame('1440-01-01', $date->copy()->startOfDecade()->format('Y-m-d')); + self::assertSame('1449-12-29', $date->copy()->endOfDecade()->format('Y-m-d')); + self::assertSame('1401-01-01', $date->copy()->startOfCentury()->format('Y-m-d')); + self::assertSame('1500-12-29', $date->copy()->endOfCentury()->format('Y-m-d')); + self::assertSame('1001-01-01', $date->copy()->startOfMillennium()->format('Y-m-d')); + self::assertSame('2000-12-29', $date->copy()->endOfMillennium()->format('Y-m-d')); + self::assertSame('2023-07-19', $date->copy()->startOfYear()->toGregorian()->format('Y-m-d')); + self::assertSame('1445-09-01', $date->copy()->startOf('month')->format('Y-m-d')); + self::assertSame('1445-09-30', $date->copy()->endOf('month')->format('Y-m-d')); + } + + public function testFirstLastAndNthWeekdays(): void + { + $date = Hijri::fromGregorian('2024-03-21'); + + self::assertSame('1445-09-01', $date->copy()->firstOfMonth()->format('Y-m-d')); + self::assertSame('1445-09-30', $date->copy()->lastOfMonth()->format('Y-m-d')); + self::assertSame('1445-09-05 Friday', $date->copy()->firstOfMonth(Carbon::FRIDAY)->format('Y-m-d l')); + self::assertSame('1445-09-26 Friday', $date->copy()->lastOfMonth(Carbon::FRIDAY)->format('Y-m-d l')); + self::assertSame('1445-09-01 Monday', $date->copy()->firstOfMonth(Carbon::MONDAY)->format('Y-m-d l')); + self::assertSame('1445-09-12 Friday', $date->copy()->nthOfMonth(2, Carbon::FRIDAY)->format('Y-m-d l')); + self::assertFalse($date->copy()->nthOfMonth(6, Carbon::MONDAY)); + self::assertSame('1445-07-02 Saturday', $date->copy()->nthOfQuarter(1, Carbon::SATURDAY)->format('Y-m-d l')); + self::assertSame('1445-01-05 Sunday', $date->copy()->nthOfYear(1, Carbon::SUNDAY)->format('Y-m-d l')); + self::assertSame('1445-01-01', $date->copy()->firstOfYear()->format('Y-m-d')); + self::assertSame('1445-12-30', $date->copy()->lastOfYear()->format('Y-m-d')); + self::assertSame('1445-07-01', $date->copy()->firstOfQuarter()->format('Y-m-d')); + self::assertSame('1445-09-30', $date->copy()->lastOfQuarter()->format('Y-m-d')); + } + + public function testHijriValues(): void + { + $date = Hijri::fromGregorian('2024-03-21'); + + self::assertSame(1445, $date->year); + self::assertSame(9, $date->month); + self::assertSame(11, $date->day); + self::assertSame(30, $date->daysInMonth); + self::assertSame(247, $date->dayOfYear); + self::assertSame(355, $date->daysInYear); + self::assertTrue($date->isLeapYear()); + self::assertSame(3, $date->quarter); + self::assertSame(145, $date->decade); + self::assertSame(15, $date->century); + self::assertSame(2, $date->weekOfMonth); + self::assertSame('Ramadan', $date->englishMonth); + self::assertSame(4, $date->dayOfWeek); + self::assertTrue($date->isThursday()); + self::assertTrue($date->copy()->endOfMonth()->isLastOfMonth()); + self::assertFalse($date->isLastOfMonth()); + self::assertFalse(Hijri::fromGregorian('2024-07-08')->isLeapYear()); + } + + public function testDifferencesInHijriMonthsAndYears(): void + { + $date = Hijri::fromGregorian('2024-03-11'); + + self::assertEquals(12, $date->diffInMonths($date->copy()->addYear(), true)); + self::assertEquals(1, $date->diffInMonths(Carbon::parse('2024-04-10'), true)); + self::assertEquals(0, (int) $date->diffInMonths(Carbon::parse('2024-04-09'), true)); + self::assertEquals(12, (int) $date->diffInMonths(Carbon::parse('2025-03-01'), true)); + self::assertEquals(4, (int) $date->diffInQuarters(Carbon::parse('2025-03-01'), true)); + self::assertEquals(34, (int) $date->diffInYears(Carbon::parse('2057-03-11'), true)); + // 2056-03-11 is 1478-08-23. + self::assertEquals(32, (int) $date->diffInYears(Carbon::parse('2056-03-11'), true)); + self::assertEquals(1, (int) $date->diffInYears($date->copy()->addYear(), true)); + self::assertEquals(1, (int) Hijri::fromGregorian('2025-03-01')->diffInYears($date, true)); + + Carbon::setTestNow('2026-10-07'); + + try { + // 1990-01-01 is 1410-06-04; 2026-10-07 is 1448-04-25. + self::assertSame(37, Hijri::fromGregorian('1990-01-01')->age); + } finally { + Carbon::setTestNow(); + } + } + + public function testSameUnitChecksAreHijri(): void + { + $date = Hijri::fromGregorian('2024-03-11'); + + self::assertTrue($date->isSameMonth(Carbon::parse('2024-04-09'))); + self::assertFalse($date->isSameMonth(Carbon::parse('2024-04-10'))); + self::assertFalse($date->isSameMonth(Carbon::parse('2024-03-10'))); + self::assertTrue($date->isSameYear(Carbon::parse('2024-07-06'))); + self::assertFalse($date->isSameYear(Carbon::parse('2024-07-08'))); + self::assertTrue($date->isSameQuarter(Carbon::parse('2024-01-13'))); + self::assertFalse($date->isSameQuarter(Carbon::parse('2024-04-10'))); + self::assertTrue($date->isSameUnit('month', Carbon::parse('2024-03-20'))); + self::assertTrue($date->isBirthday(Carbon::parse('2025-03-01'))); + self::assertFalse($date->isBirthday(Carbon::parse('2025-03-11'))); + self::assertTrue(Hijri::now()->isCurrentMonth()); + self::assertTrue(Hijri::now()->isCurrentYear()); + self::assertTrue(Hijri::now()->addMonth()->isNextMonth()); + } + + public function testTheAdjustmentOfTheInstanceIsKept(): void + { + $date = Hijri::fromGregorian('2024-03-11', null, 1); + + self::assertSame('1445-09-03', $date->format('Y-m-d')); + self::assertSame('1445-10-03', $date->copy()->addMonth()->format('Y-m-d')); + self::assertSame('2024-04-10', $date->copy()->addMonth()->toGregorian()->format('Y-m-d')); + self::assertSame('2024-03-09', $date->copy()->startOfMonth()->toGregorian()->format('Y-m-d')); + + Hijri::getInstance()->setHijriAdjustment(0); + + self::assertSame('1445-09-03', $date->format('Y-m-d')); + self::assertSame('1445-09-02', Hijri::fromGregorian('2024-03-11')->format('Y-m-d')); + self::assertSame(Hijri::fromGregorian('now')->format('Y-m-d'), Hijri::now()->format('Y-m-d')); + self::assertTrue(Hijri::fromGregorian('now', null, 2)->isToday()); + self::assertTrue(Hijri::fromGregorian('yesterday', null, 2)->isYesterday()); + self::assertTrue(Hijri::fromGregorian('tomorrow', null, 2)->isTomorrow()); + } + + public function testTimezones(): void + { + $date = Hijri::fromGregorian('2024-03-10 23:00', 'UTC'); + + self::assertSame('1445-08-29', $date->format('Y-m-d')); + self::assertSame('1445-09-01 02:00', $date->copy()->setTimezone('Asia/Riyadh')->format('Y-m-d H:i')); + self::assertSame($date->getTimestamp(), $date->copy()->setTimezone('Asia/Riyadh')->getTimestamp()); + + $shifted = Hijri::fromGregorian('2024-03-11 10:00', 'UTC')->shiftTimezone('Asia/Riyadh'); + + self::assertSame('1445-09-01 10:00 Asia/Riyadh', $shifted->format('Y-m-d H:i e')); + self::assertSame('2024-03-11T10:00:00+03:00', $shifted->toGregorian()->format('c')); + } + + public function testFromGregorianKeepsTheExactInstant(): void + { + if (PHP_VERSION_ID < 80100) { + self::markTestSkipped('PHP 8.0 loses the second of two repeated hours in DateTime::setTimezone().'); + } + + // 01:30 happens twice in New York on 2024-11-03; this is the second one. + $repeated = (new DateTime('@1730615400'))->setTimezone(new DateTimeZone('America/New_York')); + $date = Hijri::fromGregorian($repeated); + + self::assertSame(1730615400, $date->getTimestamp()); + self::assertSame('1446-05-01 01:30 America/New_York', $date->format('Y-m-d H:i e')); + self::assertSame(1730615400, $date->toGregorian()->getTimestamp()); + self::assertSame('2024-11-03 06:30', Hijri::fromGregorian($repeated, 'UTC')->rawFormat('Y-m-d H:i')); + } + + public function testStrings(): void + { + $date = Hijri::fromGregorian('2024-03-11 08:30:15', 'UTC')->locale('en'); + + self::assertSame('1445-09-01 08:30:15', (string) $date); + self::assertSame('1445-09-01', $date->toDateString()); + self::assertSame('1445-09-01 08:30:15', $date->toDateTimeString()); + self::assertSame('1445-09-01 08:30', $date->toDateTimeString('minute')); + self::assertSame('Ramadan 1, 1445', $date->toFormattedDateString()); + self::assertSame('Mon, Ramadan 1, 1445', $date->toFormattedDayDateString()); + self::assertSame('Mon, Ramadan 1, 1445 8:30 AM', $date->toDayDateTimeString()); + self::assertSame('1445-09-01T08:30:15+00:00', $date->format('c')); + self::assertSame('2024-03-11T08:30:15.000000Z', $date->toISOString()); + self::assertSame('"2024-03-11T08:30:15.000000Z"', json_encode($date)); + self::assertSame('2024-03-11T08:30:15+00:00', $date->toAtomString()); + + $array = $date->toArray(); + + self::assertSame([1445, 9, 1, 1], [$array['year'], $array['month'], $array['day'], $array['dayOfWeek']]); + self::assertSame('1445-09-01 08:30:15', $array['formatted']); + } + + public function testInstancesMadeByCarbonAreHijri(): void + { + self::assertSame('1445-09-01', Hijri::create(2024, 3, 11)->format('Y-m-d')); + self::assertSame('1445-09-01', Hijri::createFromDate(2024, 3, 11)->format('Y-m-d')); + self::assertSame('1445-09-01', Hijri::createFromFormat('d/m/Y', '11/03/2024')->format('Y-m-d')); + self::assertSame('Ramadan', Hijri::create(2024, 3, 11)->locale('en')->format('F')); + self::assertSame( + Carbon::now()->toHijri()->format('Y-m-d F'), + Hijri::now()->format('Y-m-d F') + ); + } + + public function testCopiesAndSerialization(): void + { + $date = Hijri::fromGregorian('2024-03-11 08:30:15.250000', 'Asia/Riyadh', 1); + $copy = unserialize(serialize($date)); + + self::assertInstanceOf(Hijri::class, $copy); + self::assertSame('1445-09-03 08:30:15.250000 Asia/Riyadh', $copy->format('Y-m-d H:i:s.u e')); + self::assertSame('2024-03-11', $copy->toGregorian()->format('Y-m-d')); + + $immutable = $date->toImmutable(); + + self::assertInstanceOf(CarbonImmutable::class, $immutable); + self::assertSame('2024-03-11 08:30:15', $immutable->format('Y-m-d H:i:s')); + self::assertSame('1445-09-03', Carbon::instance($date)->format('Y-m-d')); + self::assertSame('1445-09-02', $date->toHijri(0)->format('Y-m-d')); + } +} diff --git a/tests/Unit/HijriRegressionTest.php b/tests/Unit/HijriRegressionTest.php index 7fba5d8..f865883 100644 --- a/tests/Unit/HijriRegressionTest.php +++ b/tests/Unit/HijriRegressionTest.php @@ -7,7 +7,6 @@ use DateInterval; use DateTimeImmutable; use Pharaonic\Hijri\Converter\GregorianToHijriConverter; -use Pharaonic\Hijri\Exception\InvalidHijriDateException; use Pharaonic\Hijri\Hijri; use Pharaonic\Hijri\HijriCarbon; use PHPUnit\Framework\TestCase; @@ -213,13 +212,14 @@ public function testEveryDayMatchesTheConverter(): void } } - public function testMutatedInstancesKeepTheirPreviousBehaviour(): void + public function testChangedInstancesKeepReadingTheHijriDate(): void { $date = Hijri::fromGregorian('2024-03-11', null, -1)->addDay(); self::assertSame(2, $date->day); self::assertSame('1445-09-02', $date->format('Y-m-d')); - self::assertSame(Carbon::parse('1445-09-02')->dayOfWeek, $date->dayOfWeek); + self::assertSame(2, $date->dayOfWeek); + self::assertTrue($date->isTuesday()); } public function testWeekdayPropertiesFollowTheOriginalDate(): void @@ -303,18 +303,7 @@ public function testToGregorianOfAChangedInstance(): void ); } - public function testToGregorianOfAChangedInstanceThatIsNotAHijriDate(): void - { - // Sha'aban 1445 has 29 days. - $date = Hijri::fromGregorian('2024-02-11')->setDay(30); - - $this->expectException(InvalidHijriDateException::class); - $this->expectExceptionMessage('Invalid Hijri date: 1445-08-30.'); - - $date->toGregorian(); - } - - public function testToGregorianOfAnInstanceThatWasNeverConverted(): void + public function testToGregorianOfAnInstanceMadeByCarbon(): void { $date = Hijri::create(2024, 3, 11, 10, 30, 0, 'UTC'); diff --git a/tests/phpstan-carbon.php b/tests/phpstan-carbon.php new file mode 100644 index 0000000..926b13d --- /dev/null +++ b/tests/phpstan-carbon.php @@ -0,0 +1,19 @@ +hasReturnType(); + +return [ + 'parameters' => [ + 'excludePaths' => [ + 'analyseAndScan' => [ + __DIR__ . '/../src/Concerns/' . ($carbon3 ? 'Carbon2' : 'Carbon3'), + ], + ], + ], +];