Skip to content

Commit bff5ec2

Browse files
authored
Release/2.1.0 (#10)
* chore: Update member ordering rules and PHPStan level. * refactor: Standardize PHPDoc wording for predicate and conversion methods. * test: Rewrite existing test suite to full BDD Given/When/Then format. * feat: Add Precision enum and sub-second output to Instant.toIso8601. * feat: Add LocalDate value object. * docs: Document LocalDate and sub-second Instant precision in README.
1 parent a7b871f commit bff5ec2

19 files changed

Lines changed: 1725 additions & 240 deletions

.claude/rules/php-library-code-style.md

Lines changed: 35 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,45 @@ Verify every item before producing any PHP code. If any item fails, revise befor
4444
5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default,
4545
with documented exceptions for extension points and for parents that are not `readonly`.
4646
6. Members are ordered constants first, then constructor, then static methods, then instance
47-
methods. Within each group, order by body size ascending (number of lines between `{` and `}`).
48-
Constants and enum cases, which have no body, are ordered by name length ascending. This
49-
ordering may be overridden only when the alternative carries explicit documentation value:
50-
grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc),
51-
mirroring the order of an implemented interface, or similar evident structure. The override
52-
must be obvious at first reading.
47+
methods. Within each group, order by **member name length ascending** (count the name only,
48+
without parentheses, arguments, or return type). Constants, enum cases, and methods share
49+
the same name-length-ascending rule, applied within their respective groups. This mirrors
50+
the rule that governs constructor parameters and named arguments (rule 7). When two names
51+
have equal length, order them alphabetically. This ordering may be overridden only when the
52+
alternative carries explicit documentation value: grouping by domain class with section
53+
markers (HTTP status codes by 1xx/2xx/3xx/etc), mirroring the order of an implemented
54+
interface, or similar evident structure. The override must be obvious at first reading.
5355

5456
**At call sites** (chained method calls in production code, tests, or documentation
55-
examples), consecutive method invocations on the same receiver are ordered by the **visible
56-
width** of each call expression ascending. The body is not visible at the call site, so the
57-
visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and
58-
`->httpOnly()` come before parameterized `with*` builders for the same reason. When two
59-
calls have equal width, order them alphabetically by method name.
57+
examples), consecutive method invocations on the same receiver are ordered by **method name
58+
length ascending**, the same rule that governs member declarations. Boolean toggles such as
59+
`->secure()` and `->httpOnly()` come before parameterized `with*` builders because their
60+
names are shorter, not because the expression is narrower. When two method names have equal
61+
length, order them alphabetically.
6062

6163
**Terminal methods that change the receiver type** stay at the end of the chain regardless
62-
of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of
63-
work, a `send()` that flushes a request, are terminal: the chain ends with them. The
64+
of name length. A `build()` that returns the built value, a `commit()` that finalizes a unit
65+
of work, a `send()` that flushes a request, are terminal: the chain ends with them. The
6466
ordering rule applies only to consecutive calls on the same receiver type; calls that
6567
transition to a different type are not reorderable. The same applies in reverse to the
6668
factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays
6769
at its position.
70+
71+
**PHPUnit test classes** follow a dedicated sub-grouping inside the instance-methods group
72+
that overrides the name-length-ascending rule:
73+
74+
1. **Lifecycle hooks** first, in PHPUnit execution order:
75+
`setUpBeforeClass``setUp``tearDown``tearDownAfterClass`. Only those actually
76+
defined appear; never introduce an empty hook to satisfy the rule.
77+
2. **Test methods** (prefix `test`) next, ordered by name length ascending (alphabetical
78+
tiebreak).
79+
3. **Data providers** last, ordered by name length ascending (alphabetical tiebreak).
80+
81+
A method is a data provider if and only if its name appears as the string argument of a
82+
`#[DataProvider('<name>')]` attribute or a `@dataProvider <name>` docblock annotation on a
83+
test method in the same class. The naming convention (`*DataProvider`) is informational
84+
only; the reference is the authoritative signal. A method named `*DataProvider` that no
85+
test references is dead code under rule 17, not a data provider.
6886
7. Constructor parameters are ordered by parameter name length ascending (count the name only,
6987
without `$` or type), except when parameters have an implicit semantic order (for example,
7088
`$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default
@@ -225,10 +243,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali
225243

226244
### When required
227245

228-
- Every method of an interface, **including interfaces declared inside `src/Internal/`**.
229-
Interfaces define contracts. The contract is documentation by definition, regardless of
230-
namespace. The `Internal/` boundary applies to implementations, not to the contracts that
231-
internal collaborators expose to each other.
246+
- Every method of an interface.
232247
- Every public method of a concrete class outside `src/Internal/`. Public classes are at the
233248
public API boundary by definition. Consumers call every public method directly, and the
234249
PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt.
@@ -244,10 +259,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali
244259
interface. The interface carries the docblock.
245260
- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry
246261
PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the
247-
architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An
248-
interface declared inside `src/Internal/` still defines a contract, and the contract is
249-
documented per `### When required` regardless of namespace. The prohibition covers concrete
250-
classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces.
262+
architectural meaning of `Internal/`.
251263
- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz`
252264
naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in
253265
`php-library-testing.md` describe the steps. PHPDoc documentation (summary plus
@@ -270,10 +282,7 @@ The PHPDoc prohibitions above take priority over the typed-array case. When PHPS
270282

271283
- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not
272284
add PHPDoc.
273-
- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via
274-
`ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception:
275-
they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved
276-
through the PHPDoc, never through `ignoreErrors`.
285+
- On anything inside **`src/Internal/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
277286
- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
278287
- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary,
279288
`@param` descriptions, and the typed-array information. The bare-tag form remains
@@ -338,8 +347,7 @@ public function __construct(public array $entries)
338347
}
339348
```
340349

341-
**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does
342-
not extend to interfaces; see "Correct" below for an Internal/ interface):
350+
**Prohibited.** PHPDoc on anything inside `src/Internal/`:
343351

344352
```php
345353
namespace TinyBlocks\Http\Internal\Client;
@@ -353,26 +361,6 @@ final readonly class Url
353361
}
354362
```
355363

356-
**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every
357-
method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they
358-
are the contract:
359-
360-
```php
361-
namespace TinyBlocks\Http\Internal\Client;
362-
363-
interface RequestResolver
364-
{
365-
/**
366-
* Resolves the given URL against the configured base URL.
367-
*
368-
* @param string $url The path or absolute URL to resolve.
369-
* @return string The absolute URL to dispatch.
370-
* @throws MalformedPath If the URL violates RFC 3986.
371-
*/
372-
public function resolve(string $url): string;
373-
}
374-
```
375-
376364
**Correct.** Generic array type with summary and `@param` description:
377365

378366
```php

.claude/rules/php-library-testing.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Verify every item before producing any test code. If any item fails, revise befo
3636
4. No intermediate variables used only once. Chain method calls when the intermediate state is
3737
not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of
3838
`$money = Money::of(...)` followed by `$money->add(...)`).
39-
5. No private or helper methods in test classes. The only non-test methods allowed are data
39+
5. No private or helper methods in test classes. The only non-test methods allowed are PHPUnit
40+
lifecycle hooks (`setUp`, `setUpBeforeClass`, `tearDown`, `tearDownAfterClass`) and data
4041
providers. Setup logic complex enough to extract belongs in a dedicated fixture class.
4142
6. Test only the public API. Never assert on private state or `Internal/` classes directly.
4243
7. Test the behavior that **raises** an exception, never the exception itself. Exception classes
@@ -69,6 +70,8 @@ Verify every item before producing any test code. If any item fails, revise befo
6970
15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from
7071
coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See
7172
"Coverage and mutation discipline".
73+
16. Member ordering in test classes follows `php-library-code-style.md` rule 6 (PHPUnit
74+
test-class sub-grouping).
7275

7376
## Structure: Given/When/Then (BDD)
7477

README.md

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Time
22

3-
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
3+
[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/time/blob/main/LICENSE)
44

55
* [Overview](#overview)
66
* [Installation](#installation)
@@ -13,6 +13,7 @@
1313
- [Adding and subtracting time](#adding-and-subtracting-time)
1414
- [Measuring distance between instants](#measuring-distance-between-instants)
1515
- [Comparing instants](#comparing-instants)
16+
- [Emitting with sub-second precision](#emitting-with-sub-second-precision)
1617
+ [Duration](#duration)
1718
- [Creating durations](#creating-durations)
1819
- [Arithmetic](#arithmetic)
@@ -37,6 +38,13 @@
3738
- [Comparing times](#comparing-times)
3839
- [Measuring distance between times](#measuring-distance-between-times)
3940
- [Converting to other representations](#converting-to-other-representations)
41+
+ [LocalDate](#localdate)
42+
- [Creating from components](#creating-from-components-1)
43+
- [Creating from a string](#creating-from-a-string-2)
44+
- [Today in a timezone](#today-in-a-timezone)
45+
- [Projecting an Instant](#projecting-an-instant)
46+
- [Comparing dates](#comparing-dates)
47+
- [Day arithmetic](#day-arithmetic)
4048
+ [Timezone](#timezone)
4149
- [Creating from an identifier](#creating-from-an-identifier)
4250
- [Creating a UTC timezone](#creating-a-utc-timezone)
@@ -54,8 +62,8 @@
5462

5563
## Overview
5664

57-
Models time as immutable value objects for PHP, including instants, durations, periods, timezones, time-of-day, and
58-
day-of-week. All instants are normalized to UTC with microsecond precision, with strict parsing, formatting, and
65+
Models time as immutable value objects for PHP, including instants, durations, periods, timezones, time-of-day,
66+
local dates, and day-of-week. All instants are normalized to UTC with microsecond precision, with strict parsing, formatting, and
5967
arithmetic operations. Declared as `final readonly class` for language-level immutability, with structural equality
6068
provided by the tiny-blocks value-object contract.
6169

@@ -226,6 +234,27 @@ $later->isAfter(other: $earlier); # true
226234
$later->isAfterOrEqual(other: $earlier); # true
227235
```
228236

237+
#### Emitting with sub-second precision
238+
239+
By default `toIso8601()` emits seconds only. Pass a `Precision` value to include fractional
240+
seconds in the output. Existing callers that omit the argument are unaffected.
241+
242+
```php
243+
<?php
244+
245+
declare(strict_types=1);
246+
247+
use TinyBlocks\Time\Instant;
248+
use TinyBlocks\Time\Precision;
249+
250+
$instant = Instant::fromString(value: '2026-05-23T12:55:10.272097+00:00');
251+
252+
$instant->toIso8601(); # 2026-05-23T12:55:10+00:00
253+
$instant->toIso8601(precision: Precision::Seconds); # 2026-05-23T12:55:10+00:00
254+
$instant->toIso8601(precision: Precision::Microseconds); # 2026-05-23T12:55:10.272097+00:00
255+
$instant->toIso8601(precision: Precision::Milliseconds); # 2026-05-23T12:55:10.272+00:00
256+
```
257+
229258
### Duration
230259

231260
A `Duration` represents an immutable, unsigned quantity of time measured in seconds. It has no reference point on the
@@ -648,6 +677,108 @@ $time->toDuration()->toSeconds(); # 30600
648677
$time->toString(); # 08:30
649678
```
650679

680+
### LocalDate
681+
682+
A `LocalDate` is a value object representing a calendar date (year, month, day) without time and without timezone.
683+
Dates are always in the proleptic Gregorian calendar and restricted to the range `0001–9999`.
684+
685+
#### Creating from components
686+
687+
```php
688+
<?php
689+
690+
declare(strict_types=1);
691+
692+
use TinyBlocks\Time\LocalDate;
693+
694+
$date = LocalDate::of(year: 2026, month: 5, day: 23);
695+
696+
$date->year(); # 2026
697+
$date->month(); # 5
698+
$date->dayOfMonth(); # 23
699+
$date->toIso8601(); # 2026-05-23
700+
```
701+
702+
#### Creating from a string
703+
704+
Accepts only the canonical ISO 8601 date format `YYYY-MM-DD`. Any other format raises `InvalidLocalDate`.
705+
706+
```php
707+
<?php
708+
709+
declare(strict_types=1);
710+
711+
use TinyBlocks\Time\LocalDate;
712+
713+
$date = LocalDate::fromString(value: '2026-05-23');
714+
715+
$date->toIso8601(); # 2026-05-23
716+
```
717+
718+
#### Today in a timezone
719+
720+
```php
721+
<?php
722+
723+
declare(strict_types=1);
724+
725+
use TinyBlocks\Time\LocalDate;
726+
use TinyBlocks\Time\Timezone;
727+
728+
$today = LocalDate::today(zone: Timezone::from(identifier: 'America/Sao_Paulo'));
729+
730+
$today->toIso8601(); # 2026-05-23
731+
```
732+
733+
#### Projecting an Instant
734+
735+
```php
736+
<?php
737+
738+
declare(strict_types=1);
739+
740+
use TinyBlocks\Time\Instant;
741+
use TinyBlocks\Time\Timezone;
742+
743+
$instant = Instant::fromString(value: '2026-05-23T12:00:00+00:00');
744+
$date = $instant->toLocalDate(zone: Timezone::utc());
745+
746+
$date->toIso8601(); # 2026-05-23
747+
```
748+
749+
#### Comparing dates
750+
751+
```php
752+
<?php
753+
754+
declare(strict_types=1);
755+
756+
use TinyBlocks\Time\LocalDate;
757+
758+
$earlier = LocalDate::of(year: 2026, month: 1, day: 1);
759+
$later = LocalDate::of(year: 2026, month: 12, day: 31);
760+
761+
$earlier->isBefore(other: $later); # true
762+
$earlier->isBeforeOrEqual(other: $later); # true
763+
$later->isAfter(other: $earlier); # true
764+
$later->isAfterOrEqual(other: $earlier); # true
765+
```
766+
767+
#### Day arithmetic
768+
769+
```php
770+
<?php
771+
772+
declare(strict_types=1);
773+
774+
use TinyBlocks\Time\LocalDate;
775+
776+
$date = LocalDate::of(year: 2026, month: 5, day: 23);
777+
778+
$date->plusDays(days: 10)->toIso8601(); # 2026-06-02
779+
$date->minusDays(days: 30)->toIso8601(); # 2026-04-23
780+
```
781+
651782
### Timezone
652783

653784
A `Timezone` is a value object representing a single valid [IANA timezone](https://www.iana.org) identifier.

phpstan.neon.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ parameters:
44
- src
55
- tests
66
ignoreErrors:
7+
# DateTimeImmutable::createFromFormat returns DateTimeImmutable|false; the UNIX format 'U' always succeeds for a valid integer, so the false branch is unreachable.
8+
- identifier: method.nonObject
9+
path: src/Instant.php
10+
711
# Property and constructor parameter are intentionally untyped; PHPDoc on constructors is prohibited per code-style rule. Suppress missing iterable value-type plus cascading return.type and argument.type errors that propagate from the untyped property.
812
- identifier: missingType.iterableValue
913
path: src/Timezones.php
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Time\Exceptions;
6+
7+
use InvalidArgumentException;
8+
9+
final class InvalidLocalDate extends InvalidArgumentException
10+
{
11+
public static function becauseValueIsInvalid(string $value): InvalidLocalDate
12+
{
13+
$template = 'The value <%s> is not a valid local date.';
14+
15+
return new InvalidLocalDate(message: sprintf($template, $value));
16+
}
17+
18+
public static function becauseComponentsAreInvalid(int $year, int $month, int $day): InvalidLocalDate
19+
{
20+
$template = 'Year <%d>, month <%d>, and day <%d> do not form a valid calendar date.';
21+
22+
return new InvalidLocalDate(message: sprintf($template, $year, $month, $day));
23+
}
24+
}

0 commit comments

Comments
 (0)