diff --git a/README.md b/README.md index 76ec29f..7b1b717 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ - [Creating from a string](#creating-from-a-string-2) - [Today in a timezone](#today-in-a-timezone) - [Projecting an Instant](#projecting-an-instant) + - [Anchoring a date to the timeline](#anchoring-a-date-to-the-timeline) - [Comparing dates](#comparing-dates) - [Day arithmetic](#day-arithmetic) - [Month and year arithmetic](#month-and-year-arithmetic) @@ -463,8 +464,7 @@ $duration->toDays(); # 0 A `MonotonicClock` exposes a high-resolution counter for measuring elapsed time, conceptually distinct from `Duration`: `Duration` is a wall-clock quantity measured in whole seconds, while a monotonic reading is an opaque nanosecond counter whose absolute value carries no calendar meaning and is only useful as the delta between two readings on the same clock. -The default -`SystemMonotonicClock` implementation is backed by PHP's `hrtime(true)`. +The default `SystemMonotonicClock` implementation is backed by PHP's `hrtime(true)`. #### Reading the current nanoseconds @@ -508,10 +508,9 @@ $elapsedNanos = $clock->nanoseconds() - $start; ### Stopwatch A `Stopwatch` separates the act of measuring from the value being measured. It captures a starting reading from a -`MonotonicClock` and exposes the accumulated interval as an `Elapsed` -value object. The clock is injected explicitly so the time source stays under the caller's control, and reading the -interval is idempotent: invoking `elapsed()` more than once returns successive measurements from the same starting -reading. +`MonotonicClock` and exposes the accumulated interval as an `Elapsed` value object. The clock is injected explicitly so +the time source stays under the caller's control, and reading the interval is idempotent: invoking `elapsed()` more than +once returns successive measurements from the same starting reading. `Elapsed` is a pure value object expressed in nanoseconds. It is distinct from `Duration`, which models wall-clock seconds, and nanosecond and second granularities are kept in separate types so the intent of each measurement stays @@ -555,8 +554,8 @@ $stopwatch->elapsed()->toMilliseconds(); # 1.5 #### Reading the elapsed interval more than once -The starting reading is captured once and never changes. Each call to `elapsed()` returns a new -`Elapsed` measured from that same anchor, so successive calls report a non-decreasing series of intervals. +The starting reading is captured once and never changes. Each call to `elapsed()` returns a new `Elapsed` measured from +that same anchor, so successive calls report a non-decreasing series of intervals. ```php toLocalDate(zone: Timezone::utc()); $date->toIso8601(); # 2026-05-23 ``` +#### Anchoring a date to the timeline + +`atTime` is the inverse of `Instant::toLocalDate`. A `LocalDate` and a `TimeOfDay` are both civil values, so the +timezone is what turns them into a point on the timeline. + +```php +atTime( + time: TimeOfDay::from(hour: 0, minute: 0), + zone: Timezone::from(identifier: 'America/Sao_Paulo') +); + +$instant->toIso8601(); # 2026-06-15T03:00:00+00:00 +``` + +Daylight saving leaves two civil times that are not one-to-one with the timeline, and both resolve deterministically. A +time inside the spring-forward gap does not exist, and it maps to the same instant as the first civil time after the +gap. A time inside the fall-back overlap happens twice, and it maps to the earlier of the two, the one still on the +pre-transition offset. + +```php +$springForward = LocalDate::of(year: 2026, month: 3, day: 8); +$newYork = Timezone::from(identifier: 'America/New_York'); + +# 02:30 does not exist on this day, so it lands where 03:30 lands. +$springForward->atTime(time: TimeOfDay::from(hour: 2, minute: 30), zone: $newYork)->toIso8601(); +# 2026-03-08T07:30:00+00:00 +``` + #### Comparing dates ```php diff --git a/src/LocalDate.php b/src/LocalDate.php index 5009727..b36e33e 100644 --- a/src/LocalDate.php +++ b/src/LocalDate.php @@ -106,6 +106,31 @@ public function month(): int return (int)$this->date->format('n'); } + /** + * Combines this date with a time of day, read in the given timezone, into an Instant. + * + *
This is the inverse of {@see Instant::toLocalDate()}. The date and the time are civil values, + * so the timezone is what anchors them to a point on the timeline.
+ * + *Daylight saving leaves two civil times that are not one-to-one with the timeline, and both + * resolve deterministically. A time inside the spring-forward gap does not exist, and it maps to the + * same instant as the first civil time after the gap. A time inside the fall-back overlap happens + * twice, and it maps to the earlier of the two, the one still on the pre-transition offset.
+ * + * @param TimeOfDay $time The civil time of day to combine with this date. + * @param Timezone $zone The timezone the civil date and time are read in. + * @return Instant The instant those civil values denote in that timezone. + */ + public function atTime(TimeOfDay $time, Timezone $zone): Instant + { + $template = '%s %02d:%02d:00'; + $civil = sprintf($template, $this->toIso8601(), $time->hour, $time->minute); + + $datetime = new DateTimeImmutable(datetime: $civil, timezone: $zone->toDateTimeZone()); + + return Instant::fromUnixSeconds(seconds: $datetime->getTimestamp()); + } + /** * Tells whether this date is strictly after another. * diff --git a/tests/Unit/LocalDateTest.php b/tests/Unit/LocalDateTest.php index a81d9e1..4a5738b 100644 --- a/tests/Unit/LocalDateTest.php +++ b/tests/Unit/LocalDateTest.php @@ -12,6 +12,7 @@ use TinyBlocks\Time\Exceptions\InvalidLocalDate; use TinyBlocks\Time\Instant; use TinyBlocks\Time\LocalDate; +use TinyBlocks\Time\TimeOfDay; use TinyBlocks\Time\Timezone; final class LocalDateTest extends TestCase @@ -1003,6 +1004,111 @@ public function testInstantToLocalDateWhenJustPastMidnightUtcInWestZoneThenPrevi self::assertSame('2026-02-17', $localDate->toIso8601()); } + #[DataProvider('civilDateTimeDataProvider')] + public function testAtTimeWhenCivilValuesGivenThenAnchorsThemToTheTimelineOfTheZone( + string $date, + int $hour, + int $minute, + string $zone, + string $expected + ): void { + /** @Given a civil date, a civil time, and the timezone they are read in */ + + /** @When the date is combined with the time in that zone */ + $instant = LocalDate::fromString(value: $date)->atTime( + time: TimeOfDay::from(hour: $hour, minute: $minute), + zone: Timezone::from(identifier: $zone) + ); + + /** @Then the resulting instant is the one those civil values denote */ + self::assertSame($expected, $instant->toIso8601()); + } + + public function testAtTimeWhenCivilTimeFallsInTheSpringForwardGapThenItResolvesForward(): void + { + /** @Given the New York day when clocks jump from 02:00 straight to 03:00 */ + $springForward = LocalDate::of(year: 2026, month: 3, day: 8); + + /** @And the civil time 02:30, which does not exist on that day */ + $newYork = Timezone::from(identifier: 'America/New_York'); + $insideTheGap = $springForward->atTime(time: TimeOfDay::from(hour: 2, minute: 30), zone: $newYork); + + /** @When the first civil time after the gap is anchored in the same zone */ + $afterTheGap = $springForward->atTime(time: TimeOfDay::from(hour: 3, minute: 30), zone: $newYork); + + /** @Then the nonexistent time lands on the same instant as the time right after the gap */ + self::assertSame($afterTheGap->toIso8601(), $insideTheGap->toIso8601()); + self::assertSame('2026-03-08T07:30:00+00:00', $insideTheGap->toIso8601()); + } + + public function testAtTimeWhenCivilTimeFallsInTheFallBackOverlapThenItResolvesToTheEarlierOccurrence(): void + { + /** @Given the New York day when 01:30 happens twice, first on -04:00 and then on -05:00 */ + $newYork = Timezone::from(identifier: 'America/New_York'); + + /** @When the ambiguous civil time is anchored in that zone */ + $ambiguous = LocalDate::of(year: 2026, month: 11, day: 1) + ->atTime(time: TimeOfDay::from(hour: 1, minute: 30), zone: $newYork); + + /** @Then it lands on the earlier of the two, the one still on the pre-transition offset */ + self::assertSame('2026-11-01T05:30:00+00:00', $ambiguous->toIso8601()); + } + + public function testAtTimeWhenTheInstantIsReadBackInTheSameZoneThenTheCivilDateSurvives(): void + { + /** @Given a civil date anchored at a time whose UTC day differs from the local one */ + $saoPaulo = Timezone::from(identifier: 'America/Sao_Paulo'); + $date = LocalDate::of(year: 2026, month: 6, day: 15); + + /** @When the resulting instant is read back as a local date in the same zone */ + $roundTripped = $date->atTime(time: TimeOfDay::from(hour: 22, minute: 0), zone: $saoPaulo) + ->toLocalDate(zone: $saoPaulo); + + /** @Then the civil date survives the round trip */ + self::assertSame($date->toIso8601(), $roundTripped->toIso8601()); + } + + public static function civilDateTimeDataProvider(): array + { + return [ + 'UTC anchors the civil values unchanged' => [ + 'date' => '2026-02-17', + 'hour' => 10, + 'minute' => 30, + 'zone' => 'UTC', + 'expected' => '2026-02-17T10:30:00+00:00' + ], + 'Zone behind UTC shifts the instant late' => [ + 'date' => '2026-02-17', + 'hour' => 10, + 'minute' => 30, + 'zone' => 'America/Sao_Paulo', + 'expected' => '2026-02-17T13:30:00+00:00' + ], + 'Local midnight is not UTC midnight' => [ + 'date' => '2026-06-15', + 'hour' => 0, + 'minute' => 0, + 'zone' => 'America/Sao_Paulo', + 'expected' => '2026-06-15T03:00:00+00:00' + ], + 'Zone ahead of UTC shifts it early' => [ + 'date' => '2026-02-17', + 'hour' => 10, + 'minute' => 30, + 'zone' => 'Europe/Lisbon', + 'expected' => '2026-02-17T10:30:00+00:00' + ], + 'Standard time before the spring jump' => [ + 'date' => '2026-03-08', + 'hour' => 1, + 'minute' => 30, + 'zone' => 'America/New_York', + 'expected' => '2026-03-08T06:30:00+00:00' + ] + ]; + } + public static function invalidStringsDataProvider(): array { return [