Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
<?php
Expand Down Expand Up @@ -960,6 +959,43 @@ $date = $instant->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
<?php

declare(strict_types=1);

use TinyBlocks\Time\LocalDate;
use TinyBlocks\Time\TimeOfDay;
use TinyBlocks\Time\Timezone;

$date = LocalDate::of(year: 2026, month: 6, day: 15);
$instant = $date->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
Expand Down
25 changes: 25 additions & 0 deletions src/LocalDate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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.
*
Expand Down
106 changes: 106 additions & 0 deletions tests/Unit/LocalDateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 [
Expand Down