From 51b5bf5850aa1d88064a11eb4047122246c498ed Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:23:20 +0000 Subject: [PATCH 1/7] added `Wireable` feature to Duration classes and declared strict types --- src/Casts/Days.php | 2 ++ src/Casts/Hours.php | 2 ++ src/Casts/Minutes.php | 2 ++ src/Casts/Seconds.php | 2 ++ src/Casts/Years.php | 2 ++ src/Duration.php | 3 ++- src/DurationImmutable.php | 3 ++- src/DurationInterface.php | 2 ++ src/Features/Arithmetic.php | 2 ++ src/Features/Builders.php | 2 ++ src/Features/Constants.php | 2 ++ src/Features/Conversion.php | 2 ++ src/Features/Formatting.php | 2 ++ src/Features/MagicProperties.php | 2 ++ src/Features/TemporalUnits.php | 2 ++ src/Features/Wireable.php | 29 +++++++++++++++++++++++++++++ src/TimeDelta.php | 3 ++- src/Wireable.php | 20 ++++++++++++++++++++ tests/Casts/Cast.php | 2 ++ tests/Casts/DaysTest.php | 2 ++ tests/Casts/HoursTest.php | 2 ++ tests/Casts/MinutesTest.php | 2 ++ tests/Casts/SecondsTest.php | 2 ++ tests/Casts/YearsTest.php | 2 ++ tests/DurationImmutableTest.php | 2 ++ tests/DurationTest.php | 4 +++- tests/TimeDeltaTest.php | 2 ++ 27 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/Features/Wireable.php create mode 100644 src/Wireable.php diff --git a/src/Casts/Days.php b/src/Casts/Days.php index f313b9b..7e5500a 100644 --- a/src/Casts/Days.php +++ b/src/Casts/Days.php @@ -1,5 +1,7 @@ totalSeconds(); + } + + /** + * Create an instance from the value stored by Livewire. + * + * @param int|string $value + * @return static + */ + public static function fromLivewire($value): static + { + return static::seconds((int) $value); + } +} diff --git a/src/TimeDelta.php b/src/TimeDelta.php index b49dc5b..94838ea 100644 --- a/src/TimeDelta.php +++ b/src/TimeDelta.php @@ -4,7 +4,7 @@ namespace AyupCreative\Duration; -final class TimeDelta implements \JsonSerializable, DurationInterface +final class TimeDelta implements \JsonSerializable, DurationInterface, Wireable { use Features\Arithmetic; use Features\Builders; @@ -13,6 +13,7 @@ final class TimeDelta implements \JsonSerializable, DurationInterface use Features\Formatting; use Features\MagicProperties; use Features\TemporalUnits; + use Features\Wireable; protected int $totalSeconds; diff --git a/src/Wireable.php b/src/Wireable.php new file mode 100644 index 0000000..23aa8af --- /dev/null +++ b/src/Wireable.php @@ -0,0 +1,20 @@ +assertEquals(26, $duration->totalHours()); $this->assertEquals(1563, $duration->totalMinutes()); $this->assertEquals(93784, $duration->totalSeconds()); - + $this->assertEquals(0, $duration->totalWeeks()); $this->assertEquals(0, $duration->totalMonths()); $this->assertEquals(0, $duration->totalYears()); diff --git a/tests/TimeDeltaTest.php b/tests/TimeDeltaTest.php index d321ddb..9d17418 100644 --- a/tests/TimeDeltaTest.php +++ b/tests/TimeDeltaTest.php @@ -1,5 +1,7 @@ Date: Wed, 4 Mar 2026 20:37:13 +0000 Subject: [PATCH 2/7] added Livewire support --- src/Features/Wireable.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Features/Wireable.php b/src/Features/Wireable.php index a946fc1..0ef9c6b 100644 --- a/src/Features/Wireable.php +++ b/src/Features/Wireable.php @@ -9,11 +9,11 @@ trait Wireable /** * Get the value that should be stored by Livewire. * - * @return int + * @return array */ - public function toLivewire(): int + public function toLivewire() { - return $this->totalSeconds(); + return ['seconds' => $this->totalSeconds]; } /** @@ -24,6 +24,6 @@ public function toLivewire(): int */ public static function fromLivewire($value): static { - return static::seconds((int) $value); + return static::seconds(...$value); } } From ad65f22f8eeba0acdb23b0680476b8dd36f0bfb1 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:26:27 +0000 Subject: [PATCH 3/7] json serialize and create from string --- readme.md | 17 +++++++++++++++++ src/Features/Builders.php | 13 +++++++++++++ src/Features/Constants.php | 2 +- src/Features/Conversion.php | 11 ++++++++--- tests/DurationImmutableTest.php | 23 +++++++++++++++++++---- tests/DurationTest.php | 22 ++++++++++++++++++++++ 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index 0f86024..11a7518 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,11 @@ $duration = DurationImmutable::hoursAndMinutes(1, 30); // From Carbon $duration = DurationImmutable::fromCarbon(\Carbon\CarbonInterval::hours(2)); + +// From a string (similar to Carbon::parse) +$duration = DurationImmutable::parse('1h 30m'); +$duration = DurationImmutable::parse('2 days'); +$duration = DurationImmutable::parse('PT1H30M'); ``` ### Accessing Units @@ -112,6 +117,18 @@ $duration->toShortHuman(); // "1d 2h 3m" // String conversion (string) $duration; // "02:03" (hh:mm) + +// JSON Serialization +$json = json_encode($duration); +/* +{ + "seconds": 5400, + "human": "1 hour 30 minutes", + "short_human": "1h 30m", + "formatted": "01:30", + "iso8601": "PT1H30M" +} +*/ ``` ### TimeDelta (Negative Durations) diff --git a/src/Features/Builders.php b/src/Features/Builders.php index b47a52e..0d1f04b 100644 --- a/src/Features/Builders.php +++ b/src/Features/Builders.php @@ -125,6 +125,19 @@ public static function make(int $days = 0, int $hours = 0, int $minutes = 0, int return new self($seconds); } + /** + * Create a duration from a string. + * + * Usage: $duration = Duration::parse('1h 30m'); + * + * @param string $string + * @return self + */ + public static function parse(string $string): self + { + return new self((int) CarbonInterval::make($string)->totalSeconds); + } + /** * Create a duration from a CarbonInterval. * diff --git a/src/Features/Constants.php b/src/Features/Constants.php index c49eaa1..ea451df 100644 --- a/src/Features/Constants.php +++ b/src/Features/Constants.php @@ -11,7 +11,7 @@ trait Constants public const SECONDS_PER_DAY = 86400; public const SECONDS_PER_WEEK = 604800; public const SECONDS_PER_MONTH = 2629800; // 30.44 days - public const SECONDS_PER_YEAR = 31557600; // 365.25 days + public const SECONDS_PER_YEAR = 31536000; // 365 days /** * Decompose the duration into days, hours, minutes, and seconds. diff --git a/src/Features/Conversion.php b/src/Features/Conversion.php index ead6e76..29853cc 100644 --- a/src/Features/Conversion.php +++ b/src/Features/Conversion.php @@ -127,10 +127,15 @@ public function toDateInterval(): DateInterval /** * Serialize the duration to JSON. * - * @return int + * @return array */ - public function jsonSerialize(): int + public function jsonSerialize(): array { - return $this->totalSeconds; + return [ + 'seconds' => $this->toSeconds(), + 'human' => $this->toHuman(), + 'short_human' => $this->toShortHuman(), + 'iso8601' => $this->toCarbonInterval()->spec(), + ]; } } diff --git a/tests/DurationImmutableTest.php b/tests/DurationImmutableTest.php index 33c98d3..6d3f9ab 100644 --- a/tests/DurationImmutableTest.php +++ b/tests/DurationImmutableTest.php @@ -146,7 +146,7 @@ public function it_can_create_from_various_units() $this->assertEquals(86400, DurationImmutable::days(1)->totalSeconds()); $this->assertEquals(604800, DurationImmutable::weeks(1)->totalSeconds()); $this->assertEquals(2629800, DurationImmutable::months(1)->totalSeconds()); - $this->assertEquals(31557600, DurationImmutable::years(1)->totalSeconds()); + $this->assertEquals(31536000, DurationImmutable::years(1)->totalSeconds()); $this->assertEquals(3660, DurationImmutable::hoursAndMinutes(1, 1)->totalSeconds()); $this->assertEquals(90061, DurationImmutable::make(1, 1, 1, 1)->totalSeconds()); @@ -164,7 +164,7 @@ public function it_can_convert_to_various_units() // This test might fail due to bug in Conversion trait $this->assertEquals(3600 / 2629800, $duration->toMonths(), '', 0.00001); - $this->assertEquals(3600 / 31557600, $duration->toYears(), '', 0.00001); + $this->assertEquals(3600 / 31536000, $duration->toYears(), '', 0.00001); } /** @test */ @@ -369,7 +369,22 @@ public function it_can_be_converted_to_mutable() /** @test */ public function it_is_json_serializable() { - $duration = DurationImmutable::seconds(100); - $this->assertEquals(100, json_decode(json_encode($duration))); + $duration = DurationImmutable::seconds(3700); // 1h 1m 40s + $json = json_encode($duration); + $data = json_decode($json, true); + + $this->assertEquals(3700, $data['seconds']); + $this->assertEquals('1 hour 1 minute', $data['human']); + $this->assertEquals('1h 1m 40s', $data['short_human']); + $this->assertEquals('PT3700S', $data['iso8601']); + } + + /** @test */ + public function it_can_be_parsed_from_a_string() + { + $this->assertEquals(3600, DurationImmutable::parse('1h')->totalSeconds()); + $this->assertEquals(5400, DurationImmutable::parse('1h 30m')->totalSeconds()); + $this->assertEquals(172800, DurationImmutable::parse('2 days')->totalSeconds()); + $this->assertEquals(5400, DurationImmutable::parse('PT1H30M')->totalSeconds()); } } diff --git a/tests/DurationTest.php b/tests/DurationTest.php index 6a0180c..237fb28 100644 --- a/tests/DurationTest.php +++ b/tests/DurationTest.php @@ -111,4 +111,26 @@ public function it_supports_magic_properties() $this->assertEquals(0, $duration->totalMonths); $this->assertEquals(0, $duration->totalYears); } + + /** @test */ + public function it_is_json_serializable() + { + $duration = Duration::seconds(3700); // 1h 1m 40s + $json = json_encode($duration); + $data = json_decode($json, true); + + $this->assertEquals(3700, $data['seconds']); + $this->assertEquals('1 hour 1 minute', $data['human']); + $this->assertEquals('1h 1m 40s', $data['short_human']); + $this->assertEquals('PT3700S', $data['iso8601']); + } + + /** @test */ + public function it_can_be_parsed_from_a_string() + { + $this->assertEquals(3600, Duration::parse('1h')->totalSeconds()); + $this->assertEquals(5400, Duration::parse('1h 30m')->totalSeconds()); + $this->assertEquals(172800, Duration::parse('2 days')->totalSeconds()); + $this->assertEquals(5400, Duration::parse('PT1H30M')->totalSeconds()); + } } From 4692bd7547d1dcd7449b80d40d72f28090b9c333 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:30:16 +0100 Subject: [PATCH 4/7] feat: add CI/CD workflow and pre-commit configuration --- .github/workflows/ci-cd.yml | 63 +++++++++++++++++++++++++++++++++++ .github/workflows/phpunit.yml | 32 ------------------ .pre-commit-config.yaml | 20 +++++++++++ 3 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/ci-cd.yml delete mode 100644 .github/workflows/phpunit.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..16f47c8 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,63 @@ +name: CI/CD + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + types: [ opened, synchronize, reopened, closed ] + +permissions: + contents: write + pull-requests: write + +jobs: + tests: + name: PHPUnit tests + runs-on: ubuntu-latest + if: github.event.action != 'closed' && !contains(github.event.head_commit.message, '--no-change') + strategy: + matrix: + php: [8.3, 8.4, 8.5] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, bcmath, pdo, sqlite + coverage: xdebug + + - name: Install Dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run Tests + env: + XDEBUG_MODE: coverage + run: vendor/bin/phpunit --colors=never --coverage-text + + release-please: + name: Release Please + needs: tests + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: google-github-actions/release-please-action@v4 + with: + release-type: php + token: ${{ secrets.GITHUB_TOKEN }} + + cleanup-release-please: + name: Cleanup release-please branch + if: > + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release-please') + runs-on: ubuntu-latest + steps: + - name: Delete release-please branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X DELETE "/repos/${{ github.repository }}/git/refs/heads/${{ github.event.pull_request.head.ref }}" diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml deleted file mode 100644 index 495d780..0000000 --- a/.github/workflows/phpunit.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: PHP Tests - -on: - push: - branches: [ main, dev ] - pull_request: - branches: [ main, dev ] - -jobs: - tests: - runs-on: ubuntu-latest - strategy: - matrix: - php: [8.2, 8.3] - - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: mbstring, bcmath, pdo, sqlite - coverage: xdebug - - - name: Install Dependencies - run: composer install --prefer-dist --no-progress --no-interaction - - - name: Run Tests - env: - XDEBUG_MODE: coverage - run: vendor/bin/phpunit --colors=never --coverage-text diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7c04ab9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +default_install_hook_types: + - pre-commit + - commit-msg + +repos: + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] + + - repo: local + hooks: + - id: phpunit + name: phpunit + entry: vendor/bin/phpunit + language: system + pass_filenames: false + always_run: true From 91f2780dcece72508970978852481eb58ddd390c Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:30:18 +0100 Subject: [PATCH 5/7] docs: add reference to pre-commit in readme --- readme.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/readme.md b/readme.md index 11a7518..6e79355 100644 --- a/readme.md +++ b/readme.md @@ -167,6 +167,15 @@ $task = Task::find(1); $task->duration_in_seconds; // Returns DurationImmutable instance ``` +## Development + +This package uses [pre-commit](https://pre-commit.com/) to maintain code quality. To set up your local development environment: + +1. Install pre-commit: `pip install pre-commit` (or your preferred method) +2. Install the git hooks: `pre-commit install --hook-type pre-commit --hook-type commit-msg` + +The hooks will automatically run PHPUnit and validate your commit messages against [Conventional Commits](https://www.conventionalcommits.org/). + ## Testing ```bash From 05bee3070aa5712ca96ee3474ce91c48d172c2eb Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:30:21 +0100 Subject: [PATCH 6/7] fix: prevent division by zero in Duration::ceilTo --- src/Duration.php | 4 ++++ tests/DurationTest.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/Duration.php b/src/Duration.php index 52c1bba..72f39aa 100644 --- a/src/Duration.php +++ b/src/Duration.php @@ -90,6 +90,10 @@ public function multiply(float $factor): self */ public function ceilTo(int $seconds): self { + if ($seconds === 0) { + return $this; + } + $seconds = (int)(ceil($this->totalSeconds / $seconds) * $seconds); $this->totalSeconds = (new self($seconds))->totalSeconds; diff --git a/tests/DurationTest.php b/tests/DurationTest.php index 237fb28..b71fa72 100644 --- a/tests/DurationTest.php +++ b/tests/DurationTest.php @@ -69,6 +69,10 @@ public function it_can_ceil_durations() $duration = Duration::days(1)->add(Duration::hours(5)); // 1d 5h $duration->ceilToDays(1); $this->assertEquals(172800, $duration->totalSeconds()); // 2d + + $duration = Duration::seconds(65); + $duration->ceilTo(0); + $this->assertEquals(65, $duration->totalSeconds()); } /** @test */ From 12eb8bdb744da3857aec43bf92d3c041028215af Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:30:24 +0100 Subject: [PATCH 7/7] chore: update PHP version constraint --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5afbeb9..c3c6b35 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ } }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { "phpunit/phpunit": "^10.0",