diff --git a/docs/en/index.rst b/docs/en/index.rst index 76a1e953..ff6faf11 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -6,6 +6,7 @@ Chronos provides a zero-dependency ``DateTimeImmutable`` extension, Date-only an * ``Cake\Chronos\Chronos`` extends ``DateTimeImmutable`` and provides many helpers. * ``Cake\Chronos\ChronosDate`` represents calendar dates unaffected by time or time zones. * ``Cake\Chronos\ChronosTime`` represents clock times independent of date or time zones. +* ``Cake\Chronos\ChronosInterval`` wraps ``DateInterval`` with ISO 8601 formatting and convenience methods. * Only safe, immutable objects. * A pluggable translation system. Only English translations are included in the library. However, ``cakephp/i18n`` can be used for full language support. @@ -223,6 +224,105 @@ timeline:: // Difference from another point in time. echo $date->diffForHumans($other); // 1 hour ago; +Interval Objects +---------------- + +PHP's ``DateInterval`` class represents a duration of time, but lacks convenient +methods for formatting and manipulation. Chronos provides ``ChronosInterval`` +which wraps ``DateInterval`` using the decorator pattern, adding ISO 8601 duration +formatting and useful convenience methods:: + + use Cake\Chronos\ChronosInterval; + + // Create from an ISO 8601 duration spec + $interval = ChronosInterval::create('P1Y2M3D'); + echo $interval; // "P1Y2M3D" + + // Create from individual values using named arguments + $interval = ChronosInterval::createFromValues(hours: 2, minutes: 30); + echo $interval; // "PT2H30M" + + // Create from a relative date string + $interval = ChronosInterval::createFromDateString('1 year + 3 days'); + + // Wrap an existing DateInterval + $diff = $date1->diff($date2); + $interval = ChronosInterval::instance($diff); + +``ChronosInterval`` proxies all standard ``DateInterval`` properties:: + + $interval = ChronosInterval::create('P1Y2M3DT4H5M6S'); + echo $interval->y; // 1 + echo $interval->m; // 2 + echo $interval->d; // 3 + echo $interval->h; // 4 + echo $interval->i; // 5 + echo $interval->s; // 6 + echo $interval->invert; // 0 + +When working with code that requires a native ``DateInterval``, use ``toNative()``:: + + $interval = ChronosInterval::create('P1D'); + someFunctionExpectingDateInterval($interval->toNative()); + +Formatting Intervals +~~~~~~~~~~~~~~~~~~~~ + +``ChronosInterval`` provides multiple ways to format intervals:: + + $interval = ChronosInterval::createFromValues(years: 1, months: 2, days: 3, hours: 4); + + // ISO 8601 duration (also used by __toString) + echo $interval->toIso8601String(); // "P1Y2M3DT4H" + echo $interval; // "P1Y2M3DT4H" + + // Standard DateInterval formatting + echo $interval->format('%y years, %m months, %d days'); + + // Human-readable relative format + echo $interval->toDateString(); // "1 year 2 months 3 days 4 hours" + +Interval Calculations +~~~~~~~~~~~~~~~~~~~~~ + +You can get totals from intervals:: + + $interval = ChronosInterval::createFromValues(days: 2, hours: 12); + + // Approximate total seconds (uses 30 days/month, 365 days/year) + echo $interval->totalSeconds(); // 216000 + + // Total days (exact if created from diff(), otherwise approximated) + echo $interval->totalDays(); // 2 + +Interval State +~~~~~~~~~~~~~~ + +Check the state of an interval:: + + $interval = ChronosInterval::create('PT0S'); + $interval->isZero(); // true + + $past = Chronos::now()->diff(Chronos::yesterday()); + ChronosInterval::instance($past)->isNegative(); // depends on order of diff + +Interval Arithmetic +~~~~~~~~~~~~~~~~~~~ + +You can add and subtract intervals:: + + $interval1 = ChronosInterval::createFromValues(hours: 2); + $interval2 = ChronosInterval::createFromValues(hours: 1, minutes: 30); + + $sum = $interval1->add($interval2); + echo $sum; // "PT3H30M" + + $diff = $interval1->sub($interval2); + echo $diff; // "PT0H30M" + +Note: Arithmetic performs simple component addition/subtraction without +normalization (e.g., 70 minutes stays as 70 minutes rather than 1 hour 10 minutes). + Formatting Strings ------------------ diff --git a/src/ChronosInterval.php b/src/ChronosInterval.php new file mode 100644 index 00000000..d212c1b3 --- /dev/null +++ b/src/ChronosInterval.php @@ -0,0 +1,425 @@ +interval = $interval; + } + + /** + * Create an interval from a specification string. + * + * @param string $spec An interval specification (e.g., 'P1Y2M3D'). + * @return static + */ + public static function create(string $spec): static + { + return new static(new DateInterval($spec)); + } + + /** + * Create an interval from individual components. + * + * @param int|null $years Years + * @param int|null $months Months + * @param int|null $weeks Weeks (converted to days) + * @param int|null $days Days + * @param int|null $hours Hours + * @param int|null $minutes Minutes + * @param int|null $seconds Seconds + * @param int|null $microseconds Microseconds + * @return static + */ + public static function createFromValues( + ?int $years = null, + ?int $months = null, + ?int $weeks = null, + ?int $days = null, + ?int $hours = null, + ?int $minutes = null, + ?int $seconds = null, + ?int $microseconds = null, + ): static { + $interval = Chronos::createInterval( + $years, + $months, + $weeks, + $days, + $hours, + $minutes, + $seconds, + $microseconds, + ); + + return new static($interval); + } + + /** + * Create an interval from a DateInterval instance. + * + * @param \DateInterval $interval The interval to wrap. + * @return static + */ + public static function instance(DateInterval $interval): static + { + return new static($interval); + } + + /** + * Create an interval from a relative date string. + * + * This wraps DateInterval::createFromDateString() which accepts + * relative date/time formats like "1 year + 2 days" or "3 months". + * + * @param string $datetime A relative date/time string. + * @return static + * @throws \InvalidArgumentException If the string cannot be parsed. + * @see https://www.php.net/manual/en/dateinterval.createfromdatestring.php + */ + public static function createFromDateString(string $datetime): static + { + $interval = DateInterval::createFromDateString($datetime); + if ($interval === false) { + throw new InvalidArgumentException("Unable to parse interval string: {$datetime}"); + } + + return new static($interval); + } + + /** + * Get the underlying DateInterval instance. + * + * Use this when you need to pass the interval to code that expects + * a native DateInterval. + * + * @return \DateInterval + */ + public function toNative(): DateInterval + { + return $this->interval; + } + + /** + * Format the interval as an ISO 8601 duration string. + * + * @return string + */ + public function toIso8601String(): string + { + $spec = 'P'; + + if ($this->interval->y) { + $spec .= $this->interval->y . 'Y'; + } + if ($this->interval->m) { + $spec .= $this->interval->m . 'M'; + } + if ($this->interval->d) { + $spec .= $this->interval->d . 'D'; + } + + if ($this->interval->h || $this->interval->i || $this->interval->s || $this->interval->f) { + $spec .= 'T'; + + if ($this->interval->h) { + $spec .= $this->interval->h . 'H'; + } + if ($this->interval->i) { + $spec .= $this->interval->i . 'M'; + } + if ($this->interval->s || $this->interval->f) { + $seconds = (string)$this->interval->s; + if ($this->interval->f) { + $fraction = rtrim(sprintf('%06d', (int)($this->interval->f * 1000000)), '0'); + if ($fraction !== '') { + $seconds .= '.' . $fraction; + } + } + $spec .= $seconds . 'S'; + } + } + + // Handle empty interval + if ($spec === 'P') { + $spec = 'PT0S'; + } + + return ($this->interval->invert ? '-' : '') . $spec; + } + + /** + * Format the interval using DateInterval::format(). + * + * @param string $format The format string. + * @return string + * @see https://www.php.net/manual/en/dateinterval.format.php + */ + public function format(string $format): string + { + return $this->interval->format($format); + } + + /** + * Get the total number of seconds in the interval. + * + * Note: This calculation assumes 30 days per month and 365 days per year, + * which is an approximation. For precise calculations, use diff() between + * specific dates. + * + * @return int + */ + public function totalSeconds(): int + { + $seconds = $this->interval->s; + $seconds += $this->interval->i * 60; + $seconds += $this->interval->h * 3600; + $seconds += $this->interval->d * 86400; + $seconds += $this->interval->m * 30 * 86400; + $seconds += $this->interval->y * 365 * 86400; + + return $this->interval->invert ? -$seconds : $seconds; + } + + /** + * Get the total number of days in the interval. + * + * If the interval was created from a diff(), this returns the exact + * total days. Otherwise, it approximates using 30 days per month + * and 365 days per year. + * + * @return int + */ + public function totalDays(): int + { + if ($this->interval->days !== false) { + return $this->interval->invert ? -$this->interval->days : $this->interval->days; + } + + $days = $this->interval->d; + $days += $this->interval->m * 30; + $days += $this->interval->y * 365; + + return $this->interval->invert ? -$days : $days; + } + + /** + * Check if this interval is negative. + * + * @return bool + */ + public function isNegative(): bool + { + return $this->interval->invert === 1; + } + + /** + * Check if this interval is zero (no duration). + * + * @return bool + */ + public function isZero(): bool + { + return $this->interval->y === 0 + && $this->interval->m === 0 + && $this->interval->d === 0 + && $this->interval->h === 0 + && $this->interval->i === 0 + && $this->interval->s === 0 + && $this->interval->f === 0.0; + } + + /** + * Add another interval to this one. + * + * Returns a new ChronosInterval with the combined values. + * Note: This performs simple addition of each component and does not + * normalize overflow (e.g., 70 minutes stays as 70 minutes). + * + * @param \DateInterval|\Cake\Chronos\ChronosInterval $interval The interval to add. + * @return static + */ + public function add(DateInterval|ChronosInterval $interval): static + { + if ($interval instanceof ChronosInterval) { + $interval = $interval->toNative(); + } + + $result = new DateInterval('P0D'); + $result->y = $this->interval->y + $interval->y; + $result->m = $this->interval->m + $interval->m; + $result->d = $this->interval->d + $interval->d; + $result->h = $this->interval->h + $interval->h; + $result->i = $this->interval->i + $interval->i; + $result->s = $this->interval->s + $interval->s; + $result->f = $this->interval->f + $interval->f; + + return new static($result); + } + + /** + * Subtract another interval from this one. + * + * Returns a new ChronosInterval with the subtracted values. + * Note: This performs simple subtraction of each component. If any + * component becomes negative, the result may be unexpected. + * + * @param \DateInterval|\Cake\Chronos\ChronosInterval $interval The interval to subtract. + * @return static + */ + public function sub(DateInterval|ChronosInterval $interval): static + { + if ($interval instanceof ChronosInterval) { + $interval = $interval->toNative(); + } + + $result = new DateInterval('P0D'); + $result->y = $this->interval->y - $interval->y; + $result->m = $this->interval->m - $interval->m; + $result->d = $this->interval->d - $interval->d; + $result->h = $this->interval->h - $interval->h; + $result->i = $this->interval->i - $interval->i; + $result->s = $this->interval->s - $interval->s; + $result->f = $this->interval->f - $interval->f; + + return new static($result); + } + + /** + * Format the interval as a strtotime()-compatible string. + * + * Returns a relative date/time string that can be used with strtotime() + * or DateInterval::createFromDateString(). + * + * @return string + */ + public function toDateString(): string + { + $parts = []; + + if ($this->interval->y) { + $parts[] = $this->interval->y . ' ' . ($this->interval->y === 1 ? 'year' : 'years'); + } + if ($this->interval->m) { + $parts[] = $this->interval->m . ' ' . ($this->interval->m === 1 ? 'month' : 'months'); + } + if ($this->interval->d) { + $parts[] = $this->interval->d . ' ' . ($this->interval->d === 1 ? 'day' : 'days'); + } + if ($this->interval->h) { + $parts[] = $this->interval->h . ' ' . ($this->interval->h === 1 ? 'hour' : 'hours'); + } + if ($this->interval->i) { + $parts[] = $this->interval->i . ' ' . ($this->interval->i === 1 ? 'minute' : 'minutes'); + } + if ($this->interval->s) { + $parts[] = $this->interval->s . ' ' . ($this->interval->s === 1 ? 'second' : 'seconds'); + } + + if ($parts === []) { + return '0 seconds'; + } + + $result = implode(' ', $parts); + + return $this->interval->invert ? '-' . $result : $result; + } + + /** + * Return the interval as an ISO 8601 duration string. + * + * @return string + */ + public function __toString(): string + { + return $this->toIso8601String(); + } + + /** + * Allow read access to DateInterval properties. + * + * @param string $name Property name. + * @return mixed + */ + public function __get(string $name): mixed + { + return $this->interval->{$name}; + } + + /** + * Check if a DateInterval property exists. + * + * @param string $name Property name. + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->interval->{$name}); + } + + /** + * Debug info. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'interval' => $this->toIso8601String(), + 'years' => $this->interval->y, + 'months' => $this->interval->m, + 'days' => $this->interval->d, + 'hours' => $this->interval->h, + 'minutes' => $this->interval->i, + 'seconds' => $this->interval->s, + 'microseconds' => $this->interval->f, + 'invert' => $this->interval->invert, + ]; + } +} diff --git a/tests/TestCase/ChronosIntervalTest.php b/tests/TestCase/ChronosIntervalTest.php new file mode 100644 index 00000000..ffefedc4 --- /dev/null +++ b/tests/TestCase/ChronosIntervalTest.php @@ -0,0 +1,316 @@ +assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + } + + public function testCreateFromValues(): void + { + $interval = ChronosInterval::createFromValues(1, 2, 0, 3, 4, 5, 6); + $this->assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + $this->assertSame(4, $interval->h); + $this->assertSame(5, $interval->i); + $this->assertSame(6, $interval->s); + } + + public function testCreateFromValuesWithWeeks(): void + { + $interval = ChronosInterval::createFromValues(weeks: 2); + $this->assertSame(14, $interval->d); + } + + public function testInstance(): void + { + $native = new DateInterval('P1D'); + $interval = ChronosInterval::instance($native); + $this->assertSame(1, $interval->d); + } + + public function testToNative(): void + { + $interval = ChronosInterval::create('P1Y'); + $native = $interval->toNative(); + $this->assertInstanceOf(DateInterval::class, $native); + $this->assertSame(1, $native->y); + } + + public function testToIso8601String(): void + { + $interval = ChronosInterval::create('P1Y2M3DT4H5M6S'); + $this->assertSame('P1Y2M3DT4H5M6S', $interval->toIso8601String()); + } + + public function testToIso8601StringDateOnly(): void + { + $interval = ChronosInterval::create('P1Y2M3D'); + $this->assertSame('P1Y2M3D', $interval->toIso8601String()); + } + + public function testToIso8601StringTimeOnly(): void + { + $interval = ChronosInterval::create('PT4H5M6S'); + $this->assertSame('PT4H5M6S', $interval->toIso8601String()); + } + + public function testToIso8601StringEmpty(): void + { + $interval = ChronosInterval::create('P0D'); + $this->assertSame('PT0S', $interval->toIso8601String()); + } + + public function testToIso8601StringNegative(): void + { + $past = new Chronos('2020-01-01'); + $future = new Chronos('2021-02-02'); + $diff = $past->diff($future); + $diff->invert = 1; + + $interval = ChronosInterval::instance($diff); + $this->assertStringStartsWith('-P', $interval->toIso8601String()); + } + + public function testToString(): void + { + $interval = ChronosInterval::create('P1Y2M'); + $this->assertSame('P1Y2M', (string)$interval); + } + + public function testFormat(): void + { + $interval = ChronosInterval::create('P1Y2M3D'); + $this->assertSame('1 years, 2 months, 3 days', $interval->format('%y years, %m months, %d days')); + } + + public function testTotalSeconds(): void + { + $interval = ChronosInterval::create('PT1H30M'); + $this->assertSame(5400, $interval->totalSeconds()); + } + + public function testTotalDays(): void + { + $interval = ChronosInterval::create('P10D'); + $this->assertSame(10, $interval->totalDays()); + } + + public function testTotalDaysFromDiff(): void + { + $start = new Chronos('2020-01-01'); + $end = new Chronos('2020-01-11'); + $diff = $start->diff($end); + + $interval = ChronosInterval::instance($diff); + $this->assertSame(10, $interval->totalDays()); + } + + public function testIsNegative(): void + { + $interval = ChronosInterval::create('P1D'); + $this->assertFalse($interval->isNegative()); + + $past = new Chronos('2020-01-01'); + $future = new Chronos('2020-01-02'); + $diff = $future->diff($past); + + $interval = ChronosInterval::instance($diff); + $this->assertTrue($interval->isNegative()); + } + + public function testIsZero(): void + { + $interval = ChronosInterval::create('P0D'); + $this->assertTrue($interval->isZero()); + + $interval = ChronosInterval::create('P1D'); + $this->assertFalse($interval->isZero()); + } + + public function testPropertyAccess(): void + { + $interval = ChronosInterval::create('P1Y2M3DT4H5M6S'); + $this->assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + $this->assertSame(4, $interval->h); + $this->assertSame(5, $interval->i); + $this->assertSame(6, $interval->s); + } + + public function testIsset(): void + { + $interval = ChronosInterval::create('P1Y'); + $this->assertTrue(isset($interval->y)); + $this->assertTrue(isset($interval->m)); + } + + public function testDebugInfo(): void + { + $interval = ChronosInterval::create('P1Y2M3D'); + $debug = $interval->__debugInfo(); + + $this->assertArrayHasKey('interval', $debug); + $this->assertArrayHasKey('years', $debug); + $this->assertArrayHasKey('months', $debug); + $this->assertArrayHasKey('days', $debug); + } + + public function testWithMicroseconds(): void + { + $interval = ChronosInterval::createFromValues(seconds: 1, microseconds: 500000); + $this->assertSame(0.5, $interval->f); + $this->assertSame('PT1.5S', $interval->toIso8601String()); + } + + public function testWithMicrosecondsPartial(): void + { + $interval = ChronosInterval::createFromValues(seconds: 1, microseconds: 123456); + $this->assertSame('PT1.123456S', $interval->toIso8601String()); + } + + public function testCreateFromDateString(): void + { + $interval = ChronosInterval::createFromDateString('1 year + 2 months'); + $this->assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + } + + public function testCreateFromDateStringComplex(): void + { + $interval = ChronosInterval::createFromDateString('3 days 4 hours'); + $this->assertSame(3, $interval->d); + $this->assertSame(4, $interval->h); + } + + public function testAdd(): void + { + $interval1 = ChronosInterval::create('P1Y2M'); + $interval2 = ChronosInterval::create('P2Y3M'); + + $result = $interval1->add($interval2); + + $this->assertSame(3, $result->y); + $this->assertSame(5, $result->m); + // Original should be unchanged + $this->assertSame(1, $interval1->y); + } + + public function testAddWithDateInterval(): void + { + $interval = ChronosInterval::create('P1D'); + $native = new DateInterval('P2D'); + + $result = $interval->add($native); + + $this->assertSame(3, $result->d); + } + + public function testAddAllComponents(): void + { + $interval1 = ChronosInterval::create('P1Y2M3DT4H5M6S'); + $interval2 = ChronosInterval::create('P1Y1M1DT1H1M1S'); + + $result = $interval1->add($interval2); + + $this->assertSame(2, $result->y); + $this->assertSame(3, $result->m); + $this->assertSame(4, $result->d); + $this->assertSame(5, $result->h); + $this->assertSame(6, $result->i); + $this->assertSame(7, $result->s); + } + + public function testSub(): void + { + $interval1 = ChronosInterval::create('P3Y5M'); + $interval2 = ChronosInterval::create('P1Y2M'); + + $result = $interval1->sub($interval2); + + $this->assertSame(2, $result->y); + $this->assertSame(3, $result->m); + // Original should be unchanged + $this->assertSame(3, $interval1->y); + } + + public function testSubWithDateInterval(): void + { + $interval = ChronosInterval::create('P5D'); + $native = new DateInterval('P2D'); + + $result = $interval->sub($native); + + $this->assertSame(3, $result->d); + } + + public function testToDateString(): void + { + $interval = ChronosInterval::create('P1Y2M3DT4H5M6S'); + $this->assertSame('1 year 2 months 3 days 4 hours 5 minutes 6 seconds', $interval->toDateString()); + } + + public function testToDateStringSingular(): void + { + $interval = ChronosInterval::create('P1Y1M1DT1H1M1S'); + $this->assertSame('1 year 1 month 1 day 1 hour 1 minute 1 second', $interval->toDateString()); + } + + public function testToDateStringPartial(): void + { + $interval = ChronosInterval::create('P2M'); + $this->assertSame('2 months', $interval->toDateString()); + } + + public function testToDateStringEmpty(): void + { + $interval = ChronosInterval::create('P0D'); + $this->assertSame('0 seconds', $interval->toDateString()); + } + + public function testToDateStringNegative(): void + { + $past = new Chronos('2020-01-01'); + $future = new Chronos('2020-01-02'); + $diff = $future->diff($past); + + $interval = ChronosInterval::instance($diff); + $this->assertStringStartsWith('-', $interval->toDateString()); + } + + public function testToDateStringRoundTrip(): void + { + $original = ChronosInterval::create('P1Y2M3D'); + $dateString = $original->toDateString(); + + $recreated = ChronosInterval::createFromDateString($dateString); + + $this->assertSame($original->y, $recreated->y); + $this->assertSame($original->m, $recreated->m); + $this->assertSame($original->d, $recreated->d); + } +}