diff --git a/README.md b/README.md index 8c16110..c04ab99 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,11 @@ use Cake\Chronos\ChronosTime; $time = new ChronosTime('14:30:00'); echo $time->format('g:i A'); // 2:30 PM -// Create from components -$time = ChronosTime::create(14, 30, 0); +// Parse or build from parts +$time = ChronosTime::parse('14:30:00'); +$time = ChronosTime::midnight()->setTime(14, 30, 0); -// Arithmetic +// Arithmetic (wraps around midnight) $later = $time->addHours(2)->addMinutes(15); ``` diff --git a/docs/en/chronos-time.md b/docs/en/chronos-time.md index f2bbc27..b0b7305 100644 --- a/docs/en/chronos-time.md +++ b/docs/en/chronos-time.md @@ -58,6 +58,115 @@ $time->startOfHour(); // 14:00:00 $time->endOfHour(); // 14:59:59 ``` +## Arithmetic + +`ChronosTime` supports adding and subtracting hours, minutes and seconds. +All operations return a new instance (ChronosTime is immutable) and **wrap +around midnight** — adding 2 hours to 23:00 yields 01:00, and subtracting +2 hours from 00:00 yields 22:00. This matches the existing setter +behavior. + +```php +$time = ChronosTime::parse('10:20:30'); + +$time->addHours(2); // 12:20:30 +$time->subHours(2); // 08:20:30 +$time->addMinutes(15); // 10:35:30 +$time->subMinutes(15); // 10:05:30 +$time->addSeconds(30); // 10:21:00 +$time->subSeconds(30); // 10:20:00 + +// Wraps around midnight +ChronosTime::parse('23:00:00')->addHours(2); // 01:00:00 +ChronosTime::parse('00:00:00')->subHours(2); // 22:00:00 +``` + +## Modifying + +`modify()` accepts the same kind of relative string PHP's +`DateTimeImmutable::modify()` does, but **only** for time-of-day units +(`hour`, `minute`, `second`, `microsecond`). Date units like `day`, +`week`, `month` or keywords like `next monday` throw +`InvalidArgumentException` — they don't have a meaning on a date-less +value. + +```php +$time = ChronosTime::parse('10:20:30'); + +$time->modify('+2 hours'); // 12:20:30 +$time->modify('-30 minutes'); // 09:50:30 +$time->modify('+5 seconds'); // 10:20:35 + +// Throws InvalidArgumentException +$time->modify('+1 day'); +$time->modify('next monday'); +``` + +## Differences + +Compute the difference between two times as either a `DateInterval` or an +integer count of hours/minutes/seconds. + +`diff()` mirrors `DateTimeInterface::diff()` — the default is a **signed** +interval; pass `$absolute = true` to drop the sign. The `diffIn*()` +methods, matching the Chronos convention, default to **absolute** and +return the sign relative to `$this` (positive when the target is later). + +```php +$t1 = ChronosTime::parse('10:00:00'); +$t2 = ChronosTime::parse('12:30:45'); + +$interval = $t1->diff($t2); // DateInterval: 2h 30m 45s +$interval = $t2->diff($t1); // DateInterval: 2h 30m 45s, invert=1 +$interval = $t2->diff($t1, true); // DateInterval: 2h 30m 45s, invert=0 + +$t1->diffInHours($t2); // 2 +$t1->diffInMinutes($t2); // 150 +$t1->diffInSeconds($t2); // 9045 + +// Signed difference relative to $this +$t1->diffInHours($t2, absolute: false); // 2 +$t2->diffInHours($t1, absolute: false); // -2 + +// $other defaults to now() +$time = ChronosTime::parse('10:00:00'); +$time->diffInMinutes(); // minutes between 10:00:00 and now +``` + +`secondsSinceMidnight()` returns the whole-second offset from the start +of the day: + +```php +ChronosTime::parse('12:30:45')->secondsSinceMidnight(); // 45045 +``` + +## Finding closest / smallest / largest + +```php +$base = ChronosTime::parse('10:00:00'); + +$base->closest( + ChronosTime::parse('09:50:00'), + ChronosTime::parse('10:30:00'), + ChronosTime::parse('11:00:00'), +); // 09:50:00 + +$base->farthest( + ChronosTime::parse('09:50:00'), + ChronosTime::parse('10:30:00'), + ChronosTime::parse('12:00:00'), +); // 12:00:00 + +$a = ChronosTime::parse('10:00:00'); +$b = ChronosTime::parse('12:00:00'); + +$a->min($b); // 10:00:00 +$a->max($b); // 12:00:00 +``` + +`min()` and `max()` default their argument to `ChronosTime::now()` when +called without one. + ## Comparisons ChronosTime provides comparison methods similar to Chronos: diff --git a/src/ChronosTime.php b/src/ChronosTime.php index f066978..b88cce1 100644 --- a/src/ChronosTime.php +++ b/src/ChronosTime.php @@ -14,6 +14,7 @@ namespace Cake\Chronos; +use DateInterval; use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; @@ -351,6 +352,305 @@ public function endOfHour(): static return $clone; } + /** + * Returns a new instance with the given number of hours added. + * + * Arithmetic wraps around midnight: adding 2 hours to 23:00:00 + * yields 01:00:00; subtracting 2 hours from 00:00:00 yields 22:00:00. + * + * @param int $value Hours to add + * @return static + */ + public function addHours(int $value): static + { + return $this->addTicks($value * self::TICKS_PER_HOUR); + } + + /** + * Returns a new instance with the given number of hours subtracted. + * + * Wraps around midnight like {@see self::addHours()}. + * + * @param int $value Hours to subtract + * @return static + */ + public function subHours(int $value): static + { + return $this->addTicks(-$value * self::TICKS_PER_HOUR); + } + + /** + * Returns a new instance with the given number of minutes added. + * + * Wraps around midnight like {@see self::addHours()}. + * + * @param int $value Minutes to add + * @return static + */ + public function addMinutes(int $value): static + { + return $this->addTicks($value * self::TICKS_PER_MINUTE); + } + + /** + * Returns a new instance with the given number of minutes subtracted. + * + * Wraps around midnight like {@see self::addHours()}. + * + * @param int $value Minutes to subtract + * @return static + */ + public function subMinutes(int $value): static + { + return $this->addTicks(-$value * self::TICKS_PER_MINUTE); + } + + /** + * Returns a new instance with the given number of seconds added. + * + * Wraps around midnight like {@see self::addHours()}. + * + * @param int $value Seconds to add + * @return static + */ + public function addSeconds(int $value): static + { + return $this->addTicks($value * self::TICKS_PER_SECOND); + } + + /** + * Returns a new instance with the given number of seconds subtracted. + * + * Wraps around midnight like {@see self::addHours()}. + * + * @param int $value Seconds to subtract + * @return static + */ + public function subSeconds(int $value): static + { + return $this->addTicks(-$value * self::TICKS_PER_SECOND); + } + + /** + * Applies a date/time modifier string. + * + * Only time-component modifiers are accepted: combinations of signed + * integers followed by `hour(s)`, `minute(s)`, `second(s)`, or + * `microsecond(s)`. Date components (`day`, `week`, `month`, `year`) + * are not allowed and will throw. The result wraps around midnight. + * + * @param string $modifier Modifier string (e.g. `+2 hours`, `-30 minutes`) + * @return static + * @throws \InvalidArgumentException When the modifier is invalid or contains date units. + */ + public function modify(string $modifier): static + { + $pattern = '/^(?:\s*[+-]?\s*\d+\s*(?:hour|minute|second|microsecond)s?\s*)+$/i'; + if (!preg_match($pattern, $modifier)) { + throw new InvalidArgumentException( + sprintf('Modifier `%s` is not a valid ChronosTime modifier.', $modifier), + ); + } + + $base = new DateTimeImmutable('1970-01-01 00:00:00', new DateTimeZone('UTC')); + $modified = $base->modify($modifier); + if ($modified === false) { + throw new InvalidArgumentException( + sprintf('Unable to apply modifier `%s` to ChronosTime.', $modifier), + ); + } + + $deltaSeconds = $modified->getTimestamp() - $base->getTimestamp(); + $deltaMicros = (int)$modified->format('u') - (int)$base->format('u'); + $deltaTicks = $deltaSeconds * self::TICKS_PER_SECOND + $deltaMicros * self::TICKS_PER_MICROSECOND; + + return $this->addTicks($deltaTicks); + } + + /** + * Returns the number of whole seconds since midnight. + * + * @return int + */ + public function secondsSinceMidnight(): int + { + return intdiv($this->ticks, self::TICKS_PER_SECOND); + } + + /** + * Returns the difference between this time and a target time as a DateInterval. + * + * Matches the signature of `DateTimeInterface::diff()` — defaults to a + * signed interval. Pass `$absolute = true` to drop the sign. + * + * @param \Cake\Chronos\ChronosTime $target Target time + * @param bool $absolute Whether to return an absolute interval + * @return \DateInterval + */ + public function diff(ChronosTime $target, bool $absolute = false): DateInterval + { + $timezone = new DateTimeZone('UTC'); + $a = new DateTimeImmutable('1970-01-01 ' . $this->format('H:i:s.u'), $timezone); + $b = new DateTimeImmutable('1970-01-01 ' . $target->format('H:i:s.u'), $timezone); + + return $a->diff($b, $absolute); + } + + /** + * Returns the difference in whole hours between this time and another. + * + * @param \Cake\Chronos\ChronosTime|null $other Target time, defaults to now + * @param bool $absolute Whether to return an absolute value + * @return int + */ + public function diffInHours(?ChronosTime $other = null, bool $absolute = true): int + { + return intdiv($this->diffInTicks($other, $absolute), self::TICKS_PER_HOUR); + } + + /** + * Returns the difference in whole minutes between this time and another. + * + * @param \Cake\Chronos\ChronosTime|null $other Target time, defaults to now + * @param bool $absolute Whether to return an absolute value + * @return int + */ + public function diffInMinutes(?ChronosTime $other = null, bool $absolute = true): int + { + return intdiv($this->diffInTicks($other, $absolute), self::TICKS_PER_MINUTE); + } + + /** + * Returns the difference in whole seconds between this time and another. + * + * @param \Cake\Chronos\ChronosTime|null $other Target time, defaults to now + * @param bool $absolute Whether to return an absolute value + * @return int + */ + public function diffInSeconds(?ChronosTime $other = null, bool $absolute = true): int + { + return intdiv($this->diffInTicks($other, $absolute), self::TICKS_PER_SECOND); + } + + /** + * Returns the ChronosTime closest to this instance. + * + * @param \Cake\Chronos\ChronosTime $first First candidate + * @param \Cake\Chronos\ChronosTime $second Second candidate + * @param \Cake\Chronos\ChronosTime ...$others Additional candidates + * @return static + */ + public function closest(ChronosTime $first, ChronosTime $second, ChronosTime ...$others): static + { + $closest = $first; + $distance = abs($this->ticks - $first->ticks); + foreach ([$second, ...$others] as $candidate) { + $candidateDistance = abs($this->ticks - $candidate->ticks); + if ($candidateDistance < $distance) { + $closest = $candidate; + $distance = $candidateDistance; + } + } + + $clone = clone $this; + $clone->ticks = $closest->ticks; + + return $clone; + } + + /** + * Returns the ChronosTime farthest from this instance. + * + * @param \Cake\Chronos\ChronosTime $first First candidate + * @param \Cake\Chronos\ChronosTime $second Second candidate + * @param \Cake\Chronos\ChronosTime ...$others Additional candidates + * @return static + */ + public function farthest(ChronosTime $first, ChronosTime $second, ChronosTime ...$others): static + { + $farthest = $first; + $distance = abs($this->ticks - $first->ticks); + foreach ([$second, ...$others] as $candidate) { + $candidateDistance = abs($this->ticks - $candidate->ticks); + if ($candidateDistance > $distance) { + $farthest = $candidate; + $distance = $candidateDistance; + } + } + + $clone = clone $this; + $clone->ticks = $farthest->ticks; + + return $clone; + } + + /** + * Returns the smaller of this instance and the other. + * + * @param \Cake\Chronos\ChronosTime|null $other Target time, defaults to now + * @return static + */ + public function min(?ChronosTime $other = null): static + { + $other ??= static::now(); + + return $this->lessThan($other) ? $this : $this->withTicks($other->ticks); + } + + /** + * Returns the larger of this instance and the other. + * + * @param \Cake\Chronos\ChronosTime|null $other Target time, defaults to now + * @return static + */ + public function max(?ChronosTime $other = null): static + { + $other ??= static::now(); + + return $this->greaterThan($other) ? $this : $this->withTicks($other->ticks); + } + + /** + * Adds a tick (microsecond) delta and wraps into [0, TICKS_PER_DAY). + * + * @param int $delta Delta to add + * @return static + */ + protected function addTicks(int $delta): static + { + $clone = clone $this; + $clone->ticks = static::mod($this->ticks + $delta, self::TICKS_PER_DAY); + + return $clone; + } + + /** + * Returns a clone with the given absolute tick value. + * + * @param int $ticks Ticks value + * @return static + */ + protected function withTicks(int $ticks): static + { + $clone = clone $this; + $clone->ticks = $ticks; + + return $clone; + } + + /** + * @param \Cake\Chronos\ChronosTime|null $other Target time, defaults to now + * @param bool $absolute Whether to return an absolute value + * @return int + */ + protected function diffInTicks(?ChronosTime $other, bool $absolute): int + { + $other ??= static::now(); + $delta = $other->ticks - $this->ticks; + + return $absolute ? abs($delta) : $delta; + } + /** * @param int $a Left side * @param int $a Right side diff --git a/tests/TestCase/ChronosTimeTest.php b/tests/TestCase/ChronosTimeTest.php index 1be8a37..6463405 100644 --- a/tests/TestCase/ChronosTimeTest.php +++ b/tests/TestCase/ChronosTimeTest.php @@ -16,6 +16,7 @@ use Cake\Chronos\Chronos; use Cake\Chronos\ChronosTime; +use DateInterval; use DateTimeImmutable; use InvalidArgumentException; @@ -376,4 +377,304 @@ public function testToArray(): void $this->assertSame(123456, $array['microsecond']); $this->assertCount(4, $array); } + + public function testAddHours(): void + { + $t = ChronosTime::parse('10:20:30.123456'); + $new = $t->addHours(2); + + $this->assertNotSame($t, $new); + $this->assertSame('10:20:30.123456', $t->format('H:i:s.u')); + $this->assertSame('12:20:30.123456', $new->format('H:i:s.u')); + + // Wraparound past midnight. + $this->assertSame( + '01:00:00.000000', + ChronosTime::parse('23:00:00')->addHours(2)->format('H:i:s.u'), + ); + + // Negative addition. + $this->assertSame( + '22:00:00.000000', + ChronosTime::parse('00:00:00')->addHours(-2)->format('H:i:s.u'), + ); + } + + public function testSubHours(): void + { + $t = ChronosTime::parse('10:20:30.123456'); + $new = $t->subHours(2); + + $this->assertNotSame($t, $new); + $this->assertSame('08:20:30.123456', $new->format('H:i:s.u')); + + // Wraparound before midnight. + $this->assertSame( + '23:00:00.000000', + ChronosTime::parse('01:00:00')->subHours(2)->format('H:i:s.u'), + ); + } + + public function testAddMinutes(): void + { + $this->assertSame( + '10:25:00.000000', + ChronosTime::parse('10:20:00')->addMinutes(5)->format('H:i:s.u'), + ); + $this->assertSame( + '00:05:00.000000', + ChronosTime::parse('23:55:00')->addMinutes(10)->format('H:i:s.u'), + ); + } + + public function testSubMinutes(): void + { + $this->assertSame( + '10:15:00.000000', + ChronosTime::parse('10:20:00')->subMinutes(5)->format('H:i:s.u'), + ); + $this->assertSame( + '23:55:00.000000', + ChronosTime::parse('00:05:00')->subMinutes(10)->format('H:i:s.u'), + ); + } + + public function testAddSeconds(): void + { + $this->assertSame( + '10:20:35.000000', + ChronosTime::parse('10:20:30')->addSeconds(5)->format('H:i:s.u'), + ); + $this->assertSame( + '00:00:05.000000', + ChronosTime::parse('23:59:55')->addSeconds(10)->format('H:i:s.u'), + ); + } + + public function testSubSeconds(): void + { + $this->assertSame( + '10:20:25.000000', + ChronosTime::parse('10:20:30')->subSeconds(5)->format('H:i:s.u'), + ); + $this->assertSame( + '23:59:55.000000', + ChronosTime::parse('00:00:05')->subSeconds(10)->format('H:i:s.u'), + ); + } + + public function testModify(): void + { + $t = ChronosTime::parse('10:20:30.123456'); + $new = $t->modify('+2 hours'); + + $this->assertNotSame($t, $new); + $this->assertSame('10:20:30.123456', $t->format('H:i:s.u')); + $this->assertSame('12:20:30.123456', $new->format('H:i:s.u')); + + $this->assertSame( + '10:25:30.123456', + ChronosTime::parse('10:20:30.123456')->modify('+5 minutes')->format('H:i:s.u'), + ); + $this->assertSame( + '10:20:35.123456', + ChronosTime::parse('10:20:30.123456')->modify('+5 seconds')->format('H:i:s.u'), + ); + + // Wraparound past midnight. + $this->assertSame( + '01:00:00.000000', + ChronosTime::parse('23:00:00')->modify('+2 hours')->format('H:i:s.u'), + ); + } + + public function testModifyInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + ChronosTime::parse('10:00:00')->modify('+1 day'); + } + + public function testModifyInvalidString(): void + { + $this->expectException(InvalidArgumentException::class); + ChronosTime::parse('10:00:00')->modify('not a valid modifier'); + } + + public function testSecondsSinceMidnight(): void + { + $this->assertSame(0, ChronosTime::midnight()->secondsSinceMidnight()); + $this->assertSame( + 12 * 3600 + 30 * 60 + 45, + ChronosTime::parse('12:30:45')->secondsSinceMidnight(), + ); + $this->assertSame( + 23 * 3600 + 59 * 60 + 59, + ChronosTime::parse('23:59:59.999999')->secondsSinceMidnight(), + ); + } + + public function testDiff(): void + { + $t1 = ChronosTime::parse('10:00:00'); + $t2 = ChronosTime::parse('12:30:45'); + $interval = $t1->diff($t2); + + $this->assertInstanceOf(DateInterval::class, $interval); + $this->assertSame(2, $interval->h); + $this->assertSame(30, $interval->i); + $this->assertSame(45, $interval->s); + $this->assertSame(0, $interval->invert); + + // Reverse order keeps sign by default (matches DateTimeInterface::diff). + $interval = $t2->diff($t1); + $this->assertSame(2, $interval->h); + $this->assertSame(30, $interval->i); + $this->assertSame(45, $interval->s); + $this->assertSame(1, $interval->invert); + + // Absolute mode drops the sign. + $interval = $t2->diff($t1, true); + $this->assertSame(2, $interval->h); + $this->assertSame(30, $interval->i); + $this->assertSame(45, $interval->s); + $this->assertSame(0, $interval->invert); + } + + public function testDiffInHours(): void + { + $t1 = ChronosTime::parse('10:00:00'); + $t2 = ChronosTime::parse('12:30:00'); + + $this->assertSame(2, $t1->diffInHours($t2)); + $this->assertSame(2, $t2->diffInHours($t1)); + $this->assertSame(-2, $t2->diffInHours($t1, false)); + $this->assertSame(2, $t1->diffInHours($t2, false)); + } + + public function testDiffInMinutes(): void + { + $t1 = ChronosTime::parse('10:00:00'); + $t2 = ChronosTime::parse('10:05:30'); + + $this->assertSame(5, $t1->diffInMinutes($t2)); + $this->assertSame(5, $t2->diffInMinutes($t1)); + $this->assertSame(-5, $t2->diffInMinutes($t1, false)); + } + + public function testDiffInSeconds(): void + { + $t1 = ChronosTime::parse('10:00:00'); + $t2 = ChronosTime::parse('10:00:45'); + + $this->assertSame(45, $t1->diffInSeconds($t2)); + $this->assertSame(45, $t2->diffInSeconds($t1)); + $this->assertSame(-45, $t2->diffInSeconds($t1, false)); + } + + public function testClosest(): void + { + $base = ChronosTime::parse('10:00:00'); + $a = ChronosTime::parse('09:50:00'); + $b = ChronosTime::parse('10:30:00'); + $c = ChronosTime::parse('11:00:00'); + + $this->assertTrue($a->equals($base->closest($a, $b, $c))); + } + + public function testClosestFirstWinsOnTie(): void + { + $base = ChronosTime::parse('10:00:00'); + $before = ChronosTime::parse('09:30:00'); + $after = ChronosTime::parse('10:30:00'); + + // Equal distance — first argument wins. + $this->assertTrue($before->equals($base->closest($before, $after))); + } + + public function testFarthest(): void + { + $base = ChronosTime::parse('10:00:00'); + $a = ChronosTime::parse('09:50:00'); + $b = ChronosTime::parse('10:30:00'); + $c = ChronosTime::parse('12:00:00'); + + $this->assertTrue($c->equals($base->farthest($a, $b, $c))); + } + + public function testFarthestFirstWinsOnTie(): void + { + $base = ChronosTime::parse('10:00:00'); + $before = ChronosTime::parse('09:00:00'); + $after = ChronosTime::parse('11:00:00'); + + $this->assertTrue($before->equals($base->farthest($before, $after))); + } + + public function testMin(): void + { + $t1 = ChronosTime::parse('10:00:00'); + $t2 = ChronosTime::parse('12:00:00'); + + $this->assertTrue($t1->equals($t1->min($t2))); + $this->assertTrue($t1->equals($t2->min($t1))); + } + + public function testMinDefaultsToNow(): void + { + Chronos::setTestNow(new Chronos('2001-01-01 12:00:00')); + + $earlier = ChronosTime::parse('10:00:00'); + $later = ChronosTime::parse('14:00:00'); + + // $earlier vs now (12:00) → earlier is smaller. + $this->assertTrue($earlier->equals($earlier->min())); + // $later vs now (12:00) → now is smaller. + $this->assertTrue(ChronosTime::parse('12:00:00')->equals($later->min())); + } + + public function testMax(): void + { + $t1 = ChronosTime::parse('10:00:00'); + $t2 = ChronosTime::parse('12:00:00'); + + $this->assertTrue($t2->equals($t1->max($t2))); + $this->assertTrue($t2->equals($t2->max($t1))); + } + + public function testMaxDefaultsToNow(): void + { + Chronos::setTestNow(new Chronos('2001-01-01 12:00:00')); + + $earlier = ChronosTime::parse('10:00:00'); + $later = ChronosTime::parse('14:00:00'); + + // $earlier vs now (12:00) → now is larger. + $this->assertTrue(ChronosTime::parse('12:00:00')->equals($earlier->max())); + // $later vs now (12:00) → later is larger. + $this->assertTrue($later->equals($later->max())); + } + + public function testDiffInHoursDefaultsToNow(): void + { + Chronos::setTestNow(new Chronos('2001-01-01 12:00:00')); + + // Sign convention (matches Chronos::diffInSeconds): $other - $this. + $this->assertSame(2, ChronosTime::parse('10:00:00')->diffInHours()); + $this->assertSame(2, ChronosTime::parse('10:00:00')->diffInHours(absolute: false)); + $this->assertSame(-3, ChronosTime::parse('15:00:00')->diffInHours(absolute: false)); + } + + public function testDiffInMinutesDefaultsToNow(): void + { + Chronos::setTestNow(new Chronos('2001-01-01 12:00:00')); + + $this->assertSame(30, ChronosTime::parse('11:30:00')->diffInMinutes()); + } + + public function testDiffInSecondsDefaultsToNow(): void + { + Chronos::setTestNow(new Chronos('2001-01-01 12:00:00')); + + $this->assertSame(45, ChronosTime::parse('11:59:15')->diffInSeconds()); + } }