From 6640a3021d616506d29ed17b6501f462615896c2 Mon Sep 17 00:00:00 2001 From: Moamen Eltouny Date: Wed, 7 Oct 2026 22:45:59 +0300 Subject: [PATCH 1/2] Read Hijri instances in the Hijri calendar on top of the real date A Hijri instance stored the Hijri date as a Gregorian one (1445 AD), so its timestamp, comparisons and differences were about 579 years off, and Carbon's date math applied Gregorian month lengths to the Hijri numbers. Keep the real date and time, and read the year, month and day from it with the instance's adjustment. Day and time math, comparisons and timestamps now work with any other date, and months, quarters, years and larger units, setDate() and the year/month/day setters, start/end and first/last/nth boundaries, month and year differences and same-month checks follow the Hijri calendar. format(), isoFormat() and the readable to*String() methods still print the Hijri date; rawFormat(), the timestamp and the ISO/JSON strings give the Gregorian one. diffInMonths() and diffInYears() return an int in Carbon 2 and a float in Carbon 3, so their overrides are loaded for the installed version. --- phpstan.neon.dist | 1 + src/Concerns/Carbon2/HijriDifference.php | 46 ++ src/Concerns/Carbon3/HijriDifference.php | 48 ++ src/Concerns/HijriDifference.php | 13 + src/Hijri.php | 884 ++++++++++++++++++----- tests/Unit/HijriDateMathTest.php | 444 ++++++++++++ tests/Unit/HijriRegressionTest.php | 19 +- tests/phpstan-carbon.php | 19 + 8 files changed, 1280 insertions(+), 194 deletions(-) create mode 100644 src/Concerns/Carbon2/HijriDifference.php create mode 100644 src/Concerns/Carbon3/HijriDifference.php create mode 100644 src/Concerns/HijriDifference.php create mode 100644 tests/Unit/HijriDateMathTest.php create mode 100644 tests/phpstan-carbon.php 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'), + ], + ], + ], +]; From cf2ce349bd3c4c6868df217d6d4fb5c12ae3d8d0 Mon Sep 17 00:00:00 2001 From: Moamen Eltouny Date: Wed, 7 Oct 2026 22:46:06 +0300 Subject: [PATCH 2/2] Document Hijri date math and comparisons --- CHANGELOG.md | 14 +++ docs/landing/02-features.md | 3 + docs/landing/04-faq.md | 4 + docs/sections/01-overview.md | 5 +- docs/sections/04-basic-usage.md | 9 ++ docs/sections/06-hijri-to-gregorian.md | 6 +- docs/sections/07-date-math.md | 101 ++++++++++++++++++ .../{07-formatting.md => 08-formatting.md} | 14 ++- ...hijri-calendar.md => 09-hijri-calendar.md} | 0 ...9-api-reference.md => 10-api-reference.md} | 27 +++-- .../{10-examples.md => 11-examples.md} | 20 +++- ...oubleshooting.md => 12-troubleshooting.md} | 22 ++-- docs/sidebar.md | 1 + 13 files changed, 195 insertions(+), 31 deletions(-) create mode 100644 docs/sections/07-date-math.md rename docs/sections/{07-formatting.md => 08-formatting.md} (78%) rename docs/sections/{08-hijri-calendar.md => 09-hijri-calendar.md} (100%) rename docs/sections/{09-api-reference.md => 10-api-reference.md} (56%) rename docs/sections/{10-examples.md => 11-examples.md} (83%) rename docs/sections/{11-troubleshooting.md => 12-troubleshooting.md} (64%) 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