├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yml │ ├── php-cs-fixer.yml │ ├── run-tests.yml │ └── update-changelog.yml ├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── docs └── img │ ├── collection-boundaries.png │ ├── collection-gaps.png │ ├── collection-intersect.png │ ├── collection-overlap-all.png │ ├── collection-subtract.png │ ├── collection-union.png │ ├── period-diff-symmetric.png │ ├── period-gap.png │ ├── period-overlap-any.png │ ├── period-overlap.png │ ├── period-renew.png │ └── period-subtract.png └── src ├── Boundaries.php ├── Exceptions ├── CannotCeilLowerPrecision.php ├── CannotComparePeriods.php ├── InvalidDate.php └── InvalidPeriod.php ├── IterableImplementation.php ├── Period.php ├── PeriodCollection.php ├── PeriodDuration.php ├── PeriodFactory.php ├── PeriodTraits ├── PeriodComparisons.php ├── PeriodGetters.php └── PeriodOperations.php ├── Precision.php └── Visualizer.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v2.4.0 17 | with: 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" 19 | 20 | - name: Auto-merge Dependabot PRs for semver-minor updates 21 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} 22 | run: gh pr merge --auto --merge "$PR_URL" 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | 27 | - name: Auto-merge Dependabot PRs for semver-patch updates 28 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 29 | run: gh pr merge --auto --merge "$PR_URL" 30 | env: 31 | PR_URL: ${{github.event.pull_request.html_url}} 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | style: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v3 12 | 13 | - name: Run PHP CS Fixer 14 | uses: docker://oskarstark/php-cs-fixer-ga 15 | with: 16 | args: --config=.php-cs-fixer.php --allow-risky=yes 17 | 18 | - name: Commit changes 19 | uses: stefanzweifel/git-auto-commit-action@v4 20 | with: 21 | commit_message: Fix styling 22 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.2, 8.1, 8.0] 13 | dependency-version: [prefer-lowest, prefer-stable] 14 | 15 | name: P${{ matrix.php }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v3 23 | with: 24 | path: ~/.composer/cache/files 25 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 32 | coverage: none 33 | 34 | - name: Install dependencies 35 | run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest 36 | 37 | - name: Execute tests 38 | run: vendor/bin/pest 39 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Update Changelog" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | with: 15 | ref: main 16 | 17 | - name: Update Changelog 18 | uses: stefanzweifel/changelog-updater-action@v1 19 | with: 20 | latest-version: ${{ github.event.release.name }} 21 | release-notes: ${{ github.event.release.body }} 22 | 23 | - name: Commit updated CHANGELOG 24 | uses: stefanzweifel/git-auto-commit-action@v4 25 | with: 26 | branch: main 27 | commit_message: Update CHANGELOG 28 | file_pattern: CHANGELOG.md 29 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | notPath('bootstrap/*') 5 | ->notPath('storage/*') 6 | ->notPath('vendor') 7 | ->in([ 8 | __DIR__ . '/src', 9 | __DIR__ . '/tests', 10 | ]) 11 | ->name('*.php') 12 | ->notName('*.blade.php') 13 | ->ignoreDotFiles(true) 14 | ->ignoreVCS(true); 15 | 16 | return (new PhpCsFixer\Config()) 17 | ->setRules([ 18 | '@PSR12' => true, 19 | 'array_syntax' => ['syntax' => 'short'], 20 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 21 | 'no_unused_imports' => true, 22 | 'not_operator_with_successor_space' => true, 23 | 'trailing_comma_in_multiline' => true, 24 | 'phpdoc_scalar' => true, 25 | 'unary_operator_spaces' => true, 26 | 'binary_operator_spaces' => true, 27 | 'blank_line_before_statement' => [ 28 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 29 | ], 30 | 'phpdoc_single_line_var_spacing' => true, 31 | 'phpdoc_var_without_name' => true, 32 | 'class_attributes_separation' => [ 33 | 'elements' => [ 34 | 'method' => 'one', 'property' => 'one', 35 | ], 36 | ], 37 | 'method_argument_space' => [ 38 | 'on_multiline' => 'ensure_fully_multiline', 39 | 'keep_multiple_spaces_after_comma' => true, 40 | ] 41 | ]) 42 | ->setFinder($finder); 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `period` will be documented in this file 4 | 5 | ## 2.4.0 - 2023-02-20 6 | 7 | ### What's Changed 8 | 9 | - Refactor tests to pest by @AyoobMH in https://github.com/spatie/period/pull/115 10 | - Add PHP 8.2 support by @patinthehat in https://github.com/spatie/period/pull/118 11 | - Add Dependabot Automation by @patinthehat in https://github.com/spatie/period/pull/117 12 | - Bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 by @dependabot in https://github.com/spatie/period/pull/119 13 | - Implement PeriodCollection Union by @EriBloo in https://github.com/spatie/period/pull/116 14 | 15 | ### New Contributors 16 | 17 | - @AyoobMH made their first contribution in https://github.com/spatie/period/pull/115 18 | - @dependabot made their first contribution in https://github.com/spatie/period/pull/119 19 | - @EriBloo made their first contribution in https://github.com/spatie/period/pull/116 20 | 21 | **Full Changelog**: https://github.com/spatie/period/compare/2.3.5...2.4.0 22 | 23 | ## 2.3.5 - 2022-10-03 24 | 25 | Revert previous release 26 | 27 | ## Make Precision consts public - 2022-10-03 28 | 29 | **Full Changelog**: https://github.com/spatie/period/compare/2.3.3...2.3.4 30 | 31 | ## 2.3.3 - 2022-03-03 32 | 33 | **Full Changelog**: https://github.com/spatie/period/compare/2.3.2...2.3.3 34 | 35 | ## 2.3.2 - 2021-12-23 36 | 37 | ## What's Changed 38 | 39 | - Error "Undefined array key 0" fix by @aliowacom in https://github.com/spatie/period/pull/105 40 | 41 | ## New Contributors 42 | 43 | - @aliowacom made their first contribution in https://github.com/spatie/period/pull/105 44 | 45 | **Full Changelog**: https://github.com/spatie/period/compare/2.3.1...2.3.2 46 | 47 | ## 2.3.1 - 2021-12-01 48 | 49 | ## What's Changed 50 | 51 | - Add PHP 8.1 Support by @patinthehat in https://github.com/spatie/period/pull/102 52 | - Improve PHP 8.1.0 support by @kyryl-bogach in https://github.com/spatie/period/pull/103 53 | 54 | ## New Contributors 55 | 56 | - @patinthehat made their first contribution in https://github.com/spatie/period/pull/102 57 | - @kyryl-bogach made their first contribution in https://github.com/spatie/period/pull/103 58 | 59 | **Full Changelog**: https://github.com/spatie/period/compare/2.3.0...2.3.1 60 | 61 | ## 2.3.0 - 2021-10-14 62 | 63 | - Add `PeriodCollection::sort()` (#97) 64 | 65 | ## 2.2.0 - 2021-10-13 66 | 67 | - Add `PeriodCollection::unique()` (#96) 68 | 69 | ## 2.1.3 - 2021-10-07 70 | 71 | - Don't initialize Period::asString in constructor 72 | 73 | ## 2.1.2 - 2021-10-07 74 | 75 | - Fix subtraction of empty PeriodCollection 76 | 77 | ## 2.1.1 - 2021-06-11 78 | 79 | - Reindex collection array after filtering values (#87) 80 | 81 | ## 2.1.0 - 2021-03-24 82 | 83 | - Add `PeriodCollection::subtract(PeriodCollection|Period $others)` (#84) 84 | - Rename parameter `PeriodCollection::overlap(PeriodCollection $others)` 85 | - Rename parameter `PeriodCollection::overlapAll(PeriodCollection ...$others)` 86 | 87 | ## 2.0.0 - 2021-03-17 88 | 89 | - Bump required PHP version to `^8.0` 90 | - Fix bug with `overlapAll` when no overlap 91 | - All period properties are now typed, this affects you if you extend from `Period` or `PeriodCollection` 92 | - Return types of several methods have been changed from `Period` to `static` 93 | - `Period::duration()` returns an instance of `PeriodDuration` 94 | - `Period::length()` now uses the Period's precision instead of always returning days 95 | - `Period::overlap()` renamed to `Period::overlapAny()` 96 | - `Period::overlapSingle()` renamed to `Period::overlap()` 97 | - `Period::diff()` renamed to `Period::subtract()` 98 | - `Period::subtract()` (previously `diff`) no longer returns the gap when there's no overlap 99 | - `Period::diffSingle()` renamed to `Period::diffSymmetric()` 100 | - `Period::contains()` now accepts both `DateTimeInterface` and `Period` 101 | - `PeriodCollection::overlap()` now accepts one or several periods 102 | - Renamed all getters like `getIncludedEnd()` and `getStart()` to `includedEnd()` and `start()`, etc. 103 | - Add `Period::fromString()` 104 | - Add `Period::asString()` 105 | 106 | ## 1.6.0 - 2021-02-24 107 | 108 | - Add `Period::renew` (#74) 109 | 110 | ## 1.5.3 - 2020-12-03 111 | 112 | - PHP8 compatibility 113 | 114 | ## 1.5.2 - 2020-11-19 115 | 116 | - Keep timezone when boundaries are timezoned (#71) 117 | 118 | ## 1.5.1 - 2020-10-21 119 | 120 | - Support multiple precisions when checking touchesWith (#68) 121 | 122 | ## 1.5.0 - 2020-03-31 123 | 124 | - Add `filter` to `PeriodCollection` 125 | 126 | ## 1.4.5 - 2020-02-05 127 | 128 | - Fix for PeriodCollection::gaps() with excluded boundaries (#58) 129 | 130 | ## 1.4.4 - 2019-08-05 131 | 132 | - ~Performance improvement in `Period::contains()` (#46)~ edit: this change wasn't merged and targeted at 2.0 133 | 134 | ## 1.4.3 - 2019-07-09 135 | 136 | - ~Improve iterator performance (#42)~ edit: this change wasn't merged and targeted at 2.0 137 | 138 | ## 1.4.2 - 2019-05-27 139 | 140 | - Allow extension of Period that forces extension of DateTimeImmutable (#38) 141 | 142 | ## 1.4.1 - 2019-04-23 143 | 144 | - Support PeriodCollection::make() 145 | - Improved PeriodCollection doc blocks 146 | 147 | ## 1.4.0 - 2019-04-23 148 | 149 | - Add `map` and `reduce` to `PeriodCollection` 150 | 151 | ## 1.3.1 - 2019-04-19 152 | 153 | - Remove unused code 154 | 155 | ## 1.3.0 - 2019-04-19 156 | 157 | - Add period collection add 158 | 159 | ## 1.2.0 - 2019-04-19 160 | 161 | - Add period collection intersect 162 | 163 | ## 1.1.3 - 2019-04-05 164 | 165 | - Even better docblock support for static return types 166 | 167 | ## 1.1.2 - 2019-04-05 168 | 169 | - Better docblock support for static return types 170 | 171 | ## 1.1.1 - 2019-02-01 172 | 173 | - Fix bug with null element in diff 174 | 175 | ## 1.1.0 - 2019-01-26 176 | 177 | - Make Period iterable 178 | 179 | ## 1.0.0 - 2019-01-17 180 | 181 | - First stable release 182 | 183 | ## 0.5.1 - 2019-01-14 184 | 185 | - Fix bug with precision not being correctly copied 186 | 187 | ## 0.5.0 - 2019-01-09 188 | 189 | - Add boundary and precision support 190 | 191 | ## 0.4.1 - 2019-01-08 192 | 193 | - No overlap returns empty collection 194 | 195 | ## 0.4.0 - 2018-12-19 196 | 197 | - Add visualizer 198 | 199 | ## 0.3.3 - 2018-12-18 200 | 201 | - Support edge case for two period diffs 202 | 203 | ## 0.3.2 - 2018-12-11 204 | 205 | - Add better return types to support inherited periods 206 | 207 | ## 0.3.0 - 2018-11-30 208 | 209 | - Add `Period::contains` 210 | 211 | ## 0.2.0 - 2018-11-27 212 | 213 | - Initial dev release 214 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for period 6 | 7 | 8 | 9 |

Complex period comparisons

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/period.svg?style=flat-square)](https://packagist.org/packages/spatie/period) 12 | [![Quality Score](https://img.shields.io/scrutinizer/g/spatie/period.svg?style=flat-square)](https://scrutinizer-ci.com/g/spatie/period) 13 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/period.svg?style=flat-square)](https://packagist.org/packages/spatie/period) 14 | 15 |
16 | 17 | This package adds support for comparing multiple dates with each other. 18 | You can calculate the overlaps and differences between n-amount of periods, 19 | as well as some more basic comparisons between two periods. 20 | 21 | Periods can be constructed from any type of `DateTime` implementation, 22 | making this package compatible with custom `DateTime` implementations like 23 | [Carbon](https://carbon.nesbot.com) 24 | (see [cmixin/enhanced-period](https://github.com/kylekatarnls/enhanced-period) to 25 | convert directly from and to CarbonPeriod). 26 | 27 | Periods are always immutable, there's never the worry about your input dates being changed. 28 | 29 | ## Support us 30 | 31 | [](https://spatie.be/github-ad-click/period) 32 | 33 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 34 | 35 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 36 | 37 | ## Installation 38 | 39 | You can install the package via composer: 40 | 41 | ```bash 42 | composer require spatie/period 43 | ``` 44 | 45 | ## Usage 46 | 47 | ### Creating periods 48 | 49 | You're encouraged to create periods using their static constructor: 50 | 51 | ```php 52 | $period = Period::make('2021-01-01', '2021-01-31'); 53 | ``` 54 | 55 | You can manually construct a period, but you'll need to manually provide its **precision** and **boundaries**. Using `Period::make`, the default precision (`Precision::DAY()`) and default boundaries (`Boundaries::EXCLUDE_NONE()`) are used. 56 | 57 | Before discussing the API provided by this package, it's important to understand both how precision and boundaries are used. 58 | 59 | #### Precision 60 | 61 | Date precision is of utmost importance if you want to reliably compare two periods. 62 | The following example: 63 | 64 | > Given two periods: `[2021-01-01, 2021-01-15]` and `[2021-01-15, 2021-01-31]`; do they overlap? 65 | 66 | At first glance the answer is "yes": they overlap on `2021-01-15`. 67 | But what if the first period ends at `2021-01-15 10:00:00`, 68 | while the second starts at `2021-01-15 15:00:00`? 69 | Now they don't anymore! 70 | 71 | This is why this package requires you to specify a precision with each period. 72 | Only periods with the same precision can be compared. 73 | 74 | A more in-depth explanation on why precision is so important can be found [here](https://stitcher.io/blog/comparing-dates). 75 | A period's precision can be specified when constructing that period: 76 | 77 | ```php 78 | Period::make('2021-01-01', '2021-02-01', Precision::DAY()); 79 | ``` 80 | 81 | The default precision is set on days. These are the available precision options: 82 | 83 | ```php 84 | Precision::YEAR() 85 | Precision::MONTH() 86 | Precision::DAY() 87 | Precision::HOUR() 88 | Precision::MINUTE() 89 | Precision::SECOND() 90 | ``` 91 | 92 | #### Boundaries 93 | 94 | By default, period comparisons are done with included boundaries. 95 | This means that these two periods overlap: 96 | 97 | ```php 98 | $a = Period::make('2021-01-01', '2021-02-01'); 99 | $b = Period::make('2021-02-01', '2021-02-28'); 100 | 101 | $a->overlapsWith($b); // true 102 | ``` 103 | 104 | The length of a period will also include both boundaries: 105 | 106 | ```php 107 | $a = Period::make('2021-01-01', '2021-01-31'); 108 | 109 | $a->length(); // 31 110 | ``` 111 | 112 | It's possible to override the boundary behaviour: 113 | 114 | ```php 115 | $a = Period::make('2021-01-01', '2021-02-01', boundaries: Boundaries::EXCLUDE_END()); 116 | $b = Period::make('2021-02-01', '2021-02-28', boundaries: Boundaries::EXCLUDE_END()); 117 | 118 | $a->overlapsWith($b); // false 119 | ``` 120 | 121 | There are four types of boundary exclusion: 122 | 123 | ```php 124 | Boundaries::EXCLUDE_NONE(); 125 | Boundaries::EXCLUDE_START(); 126 | Boundaries::EXCLUDE_END(); 127 | Boundaries::EXCLUDE_ALL(); 128 | ``` 129 | 130 | ### Reference 131 | 132 | The `Period` class offers a rich API to interact and compare with other periods and collections of periods. Take into account that only periods with the same precision can be compared: 133 | 134 | - `startsBefore(DateTimeInterface $date): bool`: whether a period starts before a given date. 135 | - `startsBeforeOrAt(DateTimeInterface $date): bool`: whether a period starts before or at a given date. 136 | - `startsAfter(DateTimeInterface $date): bool`: whether a period starts after a given date. 137 | - `startsAfterOrAt(DateTimeInterface $date): bool`: whether a period starts after or at a given date. 138 | - `startsAt(DateTimeInterface $date): bool`: whether a period starts at a given date. 139 | - `endsBefore(DateTimeInterface $date): bool`: whether a period ends before a given date. 140 | - `endsBeforeOrAt(DateTimeInterface $date): bool`: whether a period end before or at a given date. 141 | - `endsAfter(DateTimeInterface $date): bool`: whether a period ends after a given date. 142 | - `endsAfterOrAt(DateTimeInterface $date): bool`: whether a period end after or at a given date. 143 | - `endsAt(DateTimeInterface $date): bool`: whether a period starts ends at a given date. 144 | - `overlapsWith(Period $period): bool`: whether a period overlaps with another period. 145 | - `touchesWith(Period $other): bool`: whether a period touches with another period. 146 | - `contains(DateTimeInterface|Period $other): bool`: whether a period contains another period _or_ a single date. 147 | - `equals(Period $period): bool`: whether a period equals another period. 148 | 149 | --- 150 | 151 | On top of comparisons, the `Period` class also offers a bunch of operations: 152 | 153 | ### `overlap(Period ...$others): ?static` 154 | 155 | Overlaps two or more periods on each other. The resulting period will be the union of all other periods combined. 156 | 157 | ![](./docs/img/period-overlap.png) 158 | 159 | ### `overlapAny(Period ...$others): PeriodCollection` 160 | 161 | Overlaps two or more periods on each other. Whenever two or more periods overlap, that overlapping period is added to a collection which will be returned as the final result. 162 | 163 | ![](./docs/img/period-overlap-any.png) 164 | 165 | ### `subtract(Period ...$others): PeriodCollection` 166 | 167 | Subtracts one or more periods from the main period. This is the inverse operation of overlap. 168 | 169 | ![](./docs/img/period-subtract.png) 170 | 171 | ### `gap(Period $period): ?static` 172 | 173 | Gets the gap between two periods, or 0 if the periods overlap. 174 | 175 | ![](./docs/img/period-gap.png) 176 | 177 | ### `diffSymmetric(Period $other): PeriodCollection` 178 | 179 | Performs a [symmetric diff](https://www.math-only-math.com/symmetric-difference-using-Venn-diagram.html) between two periods. 180 | 181 | ![](./docs/img/period-diff-symmetric.png) 182 | 183 | ### `renew(): static` 184 | 185 | Renew the current period, creating a new period with the same length that happens _after_ the current period. 186 | 187 | ![](./docs/img/period-renew.png) 188 | 189 | --- 190 | 191 | Next, the `Period` class also has some getters: 192 | 193 | - `isStartIncluded(): bool` 194 | - `isStartExcluded(): bool` 195 | - `isEndIncluded(): bool` 196 | - `isEndExcluded(): bool` 197 | - `start(): DateTimeImmutable` 198 | - `includedStart(): DateTimeImmutable` 199 | - `end(): DateTimeImmutable` 200 | - `includedEnd(): DateTimeImmutable` 201 | - `ceilingEnd(Precision::SECOND): DateTimeImmutable` 202 | - `length(): int` 203 | - `duration(): PeriodDuration` 204 | - `precision(): Precision` 205 | - `boundaries(): Boundaries` 206 | 207 | --- 208 | 209 | The `PeriodCollection` class represents a collection of periods and has some useful methods on its own: 210 | 211 | ### `overlapAll(PeriodCollection ...$others): PeriodCollection` 212 | 213 | Overlaps all collection periods on each other. 214 | 215 | ![](./docs/img/collection-overlap-all.png) 216 | 217 | ### `subtract(PeriodCollection|Period ...$others): PeriodCollection` 218 | 219 | Subtracts a period or a collection of periods from a period collection. 220 | 221 | ![](./docs/img/collection-subtract.png) 222 | 223 | ### `boundaries(): ?Period` 224 | 225 | Creates a new period representing the outer boundaries of the collection. 226 | 227 | ![](./docs/img/collection-boundaries.png) 228 | 229 | ### `gaps(): static` 230 | 231 | Gives the gaps for all periods within this collection. 232 | 233 | ![](./docs/img/collection-gaps.png) 234 | 235 | ### `intersect(Period $intersection): static` 236 | 237 | Intersects given period with every period within a collection. The result is a new collection of overlapping periods between given period and every period in the collection. When there's no overlap, the original period is discarded. 238 | 239 | ![](./docs/img/collection-intersect.png) 240 | 241 | ### `union(): static` 242 | 243 | Merges all periods in collection with overlapping ranges. 244 | 245 | ![](./docs/img/collection-union.png) 246 | 247 | --- 248 | 249 | Finally, there are a few utility methods available on `PeriodCollection` as well: 250 | 251 | - `add(Period ...$periods): static` 252 | - `map(Closure $closure): static`: 253 | - `reduce(Closure $closure, $initial = null): mixed`: 254 | - `filter(Closure $closure): static`: 255 | - `isEmpty(): bool`: 256 | 257 | ### Compatibility 258 | 259 | You can construct a `Period` from any type of `DateTime` object such as Carbon: 260 | 261 | ```php 262 | Period::make(Carbon::make('2021-01-01'), Carbon::make('2021-01-02')); 263 | ``` 264 | 265 | Note that as soon as a period is constructed, all further operations on it are immutable. 266 | There's never the danger of changing the input dates. 267 | 268 | You can iterate a `Period` like a regular `DatePeriod` with the precision specified on creation: 269 | 270 | ```php 271 | $datePeriod = Period::make(Carbon::make('2021-01-01'), Carbon::make('2021-01-31')); 272 | 273 | foreach ($datePeriod as $date) { 274 | /** @var DateTimeImmutable $date */ 275 | // 2021-01-01 276 | // 2021-01-02 277 | // ... 278 | // (31 iterations) 279 | } 280 | 281 | $timePeriod = Period::make(Carbon::make('2021-01-01 00:00:00'), Carbon::make('2021-01-01 23:59:59'), Precision::HOUR()); 282 | 283 | foreach ($timePeriod as $time) { 284 | /** @var DateTimeImmutable $time */ 285 | // 2021-01-01 00:00:00 286 | // 2021-01-01 01:00:00 287 | // ... 288 | // (24 iterations) 289 | } 290 | ``` 291 | 292 | ### Visualizing periods 293 | 294 | You can visualize one or more `Period` objects as well as `PeriodCollection` 295 | objects to see how they related to one another: 296 | 297 | ```php 298 | $visualizer = new Visualizer(["width" => 27]); 299 | 300 | $visualizer->visualize([ 301 | "A" => Period::make('2021-01-01', '2021-01-31'), 302 | "B" => Period::make('2021-02-10', '2021-02-20'), 303 | "C" => Period::make('2021-03-01', '2021-03-31'), 304 | "D" => Period::make('2021-01-20', '2021-03-10'), 305 | "OVERLAP" => new PeriodCollection( 306 | Period::make('2021-01-20', '2021-01-31'), 307 | Period::make('2021-02-10', '2021-02-20'), 308 | Period::make('2021-03-01', '2021-03-10') 309 | ), 310 | ]); 311 | ``` 312 | 313 | And visualize will return the following string: 314 | 315 | ``` 316 | A [========] 317 | B [==] 318 | C [========] 319 | D [==============] 320 | OVERLAP [===] [==] [==] 321 | ``` 322 | 323 | The visualizer has a configurable width provided upon creation 324 | which will control the bounds of the displayed periods: 325 | 326 | ```php 327 | $visualizer = new Visualizer(["width" => 10]); 328 | ``` 329 | 330 | ### Testing 331 | 332 | ``` bash 333 | composer test 334 | ``` 335 | 336 | ### Changelog 337 | 338 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 339 | 340 | ## Contributing 341 | 342 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 343 | 344 | ### Security 345 | 346 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 347 | 348 | ## Postcardware 349 | 350 | You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. 351 | 352 | Our address is: Spatie, Kruikstraat 22, 2021 Antwerp, Belgium. 353 | 354 | We publish all received postcards [on our company website](https://spatie.be/en/opensource/postcards). 355 | 356 | ## Credits 357 | 358 | - [Brent Roose](https://github.com/brendt) 359 | - [All Contributors](../../contributors) 360 | 361 | ## License 362 | 363 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 364 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/period", 3 | "description": "Complex period comparisons", 4 | "keywords": [ 5 | "spatie", 6 | "period" 7 | ], 8 | "homepage": "https://github.com/spatie/period", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Brent Roose", 13 | "email": "brent@spatie.be", 14 | "homepage": "https://spatie.be", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0" 20 | }, 21 | "require-dev": { 22 | "larapack/dd": "^1.1", 23 | "nesbot/carbon": "^2.63", 24 | "pestphp/pest": "^1.22", 25 | "phpunit/phpunit": "^9.5", 26 | "spatie/ray": "^1.31" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Spatie\\Period\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Spatie\\Period\\Tests\\": "tests" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "vendor/bin/pest", 40 | "test-coverage": "vendor/bin/pest --coverage" 41 | }, 42 | "config": { 43 | "sort-packages": true, 44 | "allow-plugins": { 45 | "pestphp/pest-plugin": true 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/img/collection-boundaries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/collection-boundaries.png -------------------------------------------------------------------------------- /docs/img/collection-gaps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/collection-gaps.png -------------------------------------------------------------------------------- /docs/img/collection-intersect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/collection-intersect.png -------------------------------------------------------------------------------- /docs/img/collection-overlap-all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/collection-overlap-all.png -------------------------------------------------------------------------------- /docs/img/collection-subtract.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/collection-subtract.png -------------------------------------------------------------------------------- /docs/img/collection-union.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/collection-union.png -------------------------------------------------------------------------------- /docs/img/period-diff-symmetric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/period-diff-symmetric.png -------------------------------------------------------------------------------- /docs/img/period-gap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/period-gap.png -------------------------------------------------------------------------------- /docs/img/period-overlap-any.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/period-overlap-any.png -------------------------------------------------------------------------------- /docs/img/period-overlap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/period-overlap.png -------------------------------------------------------------------------------- /docs/img/period-renew.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/period-renew.png -------------------------------------------------------------------------------- /docs/img/period-subtract.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/period/8894c7f2f58bd25ebfd675e73474ad5eb817a009/docs/img/period-subtract.png -------------------------------------------------------------------------------- /src/Boundaries.php: -------------------------------------------------------------------------------- 1 | self::EXCLUDE_NONE(), 23 | '[)' => self::EXCLUDE_END(), 24 | '(]' => self::EXCLUDE_START(), 25 | '()' => self::EXCLUDE_ALL(), 26 | }; 27 | } 28 | 29 | public static function EXCLUDE_NONE(): self 30 | { 31 | return new self(self::EXCLUDE_NONE); 32 | } 33 | 34 | public static function EXCLUDE_START(): self 35 | { 36 | return new self(self::EXCLUDE_START); 37 | } 38 | 39 | public static function EXCLUDE_END(): self 40 | { 41 | return new self(self::EXCLUDE_END); 42 | } 43 | 44 | public static function EXCLUDE_ALL(): self 45 | { 46 | return new self(self::EXCLUDE_ALL); 47 | } 48 | 49 | public function startExcluded(): bool 50 | { 51 | return self::EXCLUDE_START & $this->mask; 52 | } 53 | 54 | public function startIncluded(): bool 55 | { 56 | return ! $this->startExcluded(); 57 | } 58 | 59 | public function endExcluded(): bool 60 | { 61 | return self::EXCLUDE_END & $this->mask; 62 | } 63 | 64 | public function endIncluded(): bool 65 | { 66 | return ! $this->endExcluded(); 67 | } 68 | 69 | public function realStart(DateTimeImmutable $includedStart, Precision $precision): DateTimeImmutable 70 | { 71 | if ($this->startIncluded()) { 72 | return $includedStart; 73 | } 74 | 75 | return $precision->decrement($includedStart); 76 | } 77 | 78 | public function realEnd(DateTimeImmutable $includedEnd, Precision $precision): DateTimeImmutable 79 | { 80 | if ($this->endIncluded()) { 81 | return $includedEnd; 82 | } 83 | 84 | return $precision->increment($includedEnd); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Exceptions/CannotCeilLowerPrecision.php: -------------------------------------------------------------------------------- 1 | intervalName()) { 21 | 'y' => 'year', 22 | 'm' => 'month', 23 | 'd' => 'day', 24 | 'h' => 'hour', 25 | 'i' => 'minute', 26 | 's' => 'second', 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exceptions/CannotComparePeriods.php: -------------------------------------------------------------------------------- 1 | format('Y-m-d H:i:s')}` is before the start time `{$start->format('Y-m-d H:i:s')}`."); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/IterableImplementation.php: -------------------------------------------------------------------------------- 1 | periods[$offset] ?? null; 12 | } 13 | 14 | public function offsetSet(mixed $offset, mixed $value): void 15 | { 16 | if (is_null($offset)) { 17 | $this->periods[] = $value; 18 | 19 | return; 20 | } 21 | 22 | $this->periods[$offset] = $value; 23 | } 24 | 25 | public function offsetExists(mixed $offset): bool 26 | { 27 | return array_key_exists($offset, $this->periods); 28 | } 29 | 30 | public function offsetUnset(mixed $offset): void 31 | { 32 | unset($this->periods[$offset]); 33 | } 34 | 35 | public function next(): void 36 | { 37 | $this->position++; 38 | } 39 | 40 | public function key(): mixed 41 | { 42 | return $this->position; 43 | } 44 | 45 | public function valid(): bool 46 | { 47 | return array_key_exists($this->position, $this->periods); 48 | } 49 | 50 | public function rewind(): void 51 | { 52 | $this->position = 0; 53 | } 54 | 55 | public function count(): int 56 | { 57 | return count($this->periods); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Period.php: -------------------------------------------------------------------------------- 1 | $end) { 37 | throw InvalidPeriod::endBeforeStart($start, $end); 38 | } 39 | 40 | $this->interval = $this->precision->interval(); 41 | $this->includedStart = $boundaries->startIncluded() ? $start : $start->add($this->interval); 42 | $this->includedEnd = $boundaries->endIncluded() ? $end : $end->sub($this->interval); 43 | $this->duration = new PeriodDuration($this); 44 | } 45 | 46 | public static function make( 47 | DateTimeInterface | string $start, 48 | DateTimeInterface | string $end, 49 | ?Precision $precision = null, 50 | ?Boundaries $boundaries = null, 51 | ?string $format = null 52 | ): static { 53 | return PeriodFactory::make( 54 | periodClass: static::class, 55 | start: $start, 56 | end: $end, 57 | precision: $precision, 58 | boundaries: $boundaries, 59 | format: $format, 60 | ); 61 | } 62 | 63 | public static function fromString(string $string): static 64 | { 65 | return PeriodFactory::fromString(static::class, $string); 66 | } 67 | 68 | public function getIterator(): DatePeriod 69 | { 70 | return new DatePeriod( 71 | $this->includedStart(), 72 | $this->interval, 73 | // We need to add 1 second (the smallest unit available within this package) to ensure entries are counted correctly 74 | $this->includedEnd()->add(new DateInterval('PT1S')) 75 | ); 76 | } 77 | 78 | protected function ensurePrecisionMatches(Period $other): void 79 | { 80 | if ($this->precision->equals($other->precision)) { 81 | return; 82 | } 83 | 84 | throw CannotComparePeriods::precisionDoesNotMatch(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/PeriodCollection.php: -------------------------------------------------------------------------------- 1 | periods = $periods; 25 | } 26 | 27 | public function current(): Period 28 | { 29 | return $this->periods[$this->position]; 30 | } 31 | 32 | public function overlapAll(PeriodCollection ...$others): PeriodCollection 33 | { 34 | $overlap = clone $this; 35 | 36 | foreach ($others as $other) { 37 | $overlap = $overlap->overlap($other); 38 | } 39 | 40 | return $overlap; 41 | } 42 | 43 | public function boundaries(): ?Period 44 | { 45 | $start = null; 46 | $end = null; 47 | 48 | foreach ($this as $period) { 49 | if ($start === null || $start > $period->includedStart()) { 50 | $start = $period->includedStart(); 51 | } 52 | 53 | if ($end === null || $end < $period->includedEnd()) { 54 | $end = $period->includedEnd(); 55 | } 56 | } 57 | 58 | if (! $start || ! $end) { 59 | return null; 60 | } 61 | 62 | [$firstPeriod] = $this->periods; 63 | 64 | return Period::make( 65 | $start, 66 | $end, 67 | $firstPeriod->precision(), 68 | Boundaries::EXCLUDE_NONE() 69 | ); 70 | } 71 | 72 | public function gaps(): static 73 | { 74 | $boundaries = $this->boundaries(); 75 | 76 | if (! $boundaries) { 77 | return static::make(); 78 | } 79 | 80 | return $boundaries->subtract(...$this); 81 | } 82 | 83 | public function intersect(Period $intersection): static 84 | { 85 | $intersected = static::make(); 86 | 87 | foreach ($this as $period) { 88 | $overlap = $intersection->overlap($period); 89 | 90 | if ($overlap === null) { 91 | continue; 92 | } 93 | 94 | $intersected[] = $overlap; 95 | } 96 | 97 | return $intersected; 98 | } 99 | 100 | public function add(Period ...$periods): static 101 | { 102 | $collection = clone $this; 103 | 104 | foreach ($periods as $period) { 105 | $collection[] = $period; 106 | } 107 | 108 | return $collection; 109 | } 110 | 111 | public function map(Closure $closure): static 112 | { 113 | $collection = clone $this; 114 | 115 | foreach ($collection->periods as $key => $period) { 116 | $collection->periods[$key] = $closure($period); 117 | } 118 | 119 | return $collection; 120 | } 121 | 122 | public function reduce(Closure $closure, $initial = null): mixed 123 | { 124 | $carry = $initial; 125 | 126 | foreach ($this as $period) { 127 | $carry = $closure($carry, $period); 128 | } 129 | 130 | return $carry; 131 | } 132 | 133 | public function filter(Closure $closure): static 134 | { 135 | $collection = clone $this; 136 | 137 | $collection->periods = array_values(array_filter($collection->periods, $closure)); 138 | 139 | return $collection; 140 | } 141 | 142 | public function isEmpty(): bool 143 | { 144 | return count($this->periods) === 0; 145 | } 146 | 147 | public function subtract(PeriodCollection | Period $others): static 148 | { 149 | if ($others instanceof Period) { 150 | $others = new static($others); 151 | } 152 | 153 | if ($others->count() === 0) { 154 | return clone $this; 155 | } 156 | 157 | $collection = new static(); 158 | 159 | foreach ($this as $period) { 160 | $collection = $collection->add(...$period->subtract(...$others)); 161 | } 162 | 163 | return $collection; 164 | } 165 | 166 | private function overlap(PeriodCollection $others): PeriodCollection 167 | { 168 | $overlaps = new PeriodCollection(); 169 | 170 | foreach ($this as $period) { 171 | foreach ($others as $other) { 172 | if (! $period->overlap($other)) { 173 | continue; 174 | } 175 | 176 | $overlaps[] = $period->overlap($other); 177 | } 178 | } 179 | 180 | return $overlaps; 181 | } 182 | 183 | public function unique(): PeriodCollection 184 | { 185 | $uniquePeriods = []; 186 | foreach ($this->periods as $period) { 187 | $uniquePeriods[$period->asString()] = $period; 188 | } 189 | 190 | return new static(...array_values($uniquePeriods)); 191 | } 192 | 193 | public function sort(): PeriodCollection 194 | { 195 | $collection = clone $this; 196 | 197 | usort($collection->periods, static function (Period $a, Period $b) { 198 | return $a->includedStart() <=> $b->includedStart(); 199 | }); 200 | 201 | return $collection; 202 | } 203 | 204 | public function union(): PeriodCollection 205 | { 206 | $boundaries = $this->boundaries(); 207 | 208 | if (! $boundaries) { 209 | return static::make(); 210 | } 211 | 212 | return static::make($boundaries)->subtract($boundaries->subtract(...$this)); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/PeriodDuration.php: -------------------------------------------------------------------------------- 1 | startAndEndDatesAreTheSameAs($other) 19 | || $this->includedStartAndEndDatesAreTheSameAs($other) 20 | || $this->numberOfDaysIsTheSameAs($other) 21 | || $this->compareTo($other) === 0; 22 | } 23 | 24 | public function isLargerThan(PeriodDuration $other): bool 25 | { 26 | return $this->compareTo($other) === 1; 27 | } 28 | 29 | public function isSmallerThan(PeriodDuration $other): bool 30 | { 31 | return $this->compareTo($other) === -1; 32 | } 33 | 34 | public function compareTo(PeriodDuration $other): int 35 | { 36 | $now = new DateTimeImmutable('@' . time()); // Ensure a TimeZone independent instance 37 | 38 | $here = $this->period->includedEnd()->diff($this->period->includedStart(), true); 39 | $there = $other->period->includedEnd()->diff($other->period->includedStart(), true); 40 | 41 | return $now->add($here)->getTimestamp() <=> $now->add($there)->getTimestamp(); 42 | } 43 | 44 | private function startAndEndDatesAreTheSameAs(PeriodDuration $other): bool 45 | { 46 | return $this->period->start() == $other->period->start() 47 | && $this->period->end() == $other->period->end(); 48 | } 49 | 50 | private function includedStartAndEndDatesAreTheSameAs(PeriodDuration $other): bool 51 | { 52 | return $this->period->includedStart() == $other->period->includedStart() 53 | && $this->period->includedEnd() == $other->period->includedEnd(); 54 | } 55 | 56 | private function numberOfDaysIsTheSameAs(PeriodDuration $other) 57 | { 58 | $here = $this->period->includedEnd()->diff($this->period->includedStart(), true); 59 | $there = $other->period->includedEnd()->diff($other->period->includedStart(), true); 60 | 61 | return $here->format('%a') === $there->format('%a'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/PeriodFactory.php: -------------------------------------------------------------------------------- 1 | $startBoundary, 2 => $startDate, 3 => $endDate, 4 => $endBoundary] = $matches; 17 | 18 | $boundaries = Boundaries::fromString($startBoundary, $endBoundary); 19 | 20 | $startDate = trim($startDate); 21 | 22 | $endDate = trim($endDate); 23 | 24 | $precision = Precision::fromString($startDate); 25 | 26 | $start = self::resolveDate($startDate, $precision->dateFormat()); 27 | 28 | $end = self::resolveDate($endDate, $precision->dateFormat()); 29 | 30 | return new $periodClass( 31 | start: $start, 32 | end: $end, 33 | precision: $precision, 34 | boundaries: $boundaries, 35 | ); 36 | } 37 | 38 | public static function make( 39 | string $periodClass, 40 | string | DateTimeInterface $start, 41 | string | DateTimeInterface $end, 42 | ?Precision $precision = null, 43 | ?Boundaries $boundaries = null, 44 | ?string $format = null 45 | ): Period { 46 | $boundaries ??= Boundaries::EXCLUDE_NONE(); 47 | $precision ??= Precision::DAY(); 48 | $start = $precision->roundDate(self::resolveDate($start, $format)); 49 | $end = $precision->roundDate(self::resolveDate($end, $format)); 50 | 51 | /** @var \Spatie\Period\Period $period */ 52 | $period = new $periodClass( 53 | start: $start, 54 | end: $end, 55 | precision: $precision, 56 | boundaries: $boundaries, 57 | ); 58 | 59 | return $period; 60 | } 61 | 62 | public static function makeWithBoundaries( 63 | string $periodClass, 64 | DateTimeImmutable $includedStart, 65 | DateTimeImmutable $includedEnd, 66 | Precision $precision, 67 | Boundaries $boundaries, 68 | ): Period { 69 | $includedStart = $precision->roundDate(self::resolveDate($includedStart)); 70 | $includedEnd = $precision->roundDate(self::resolveDate($includedEnd)); 71 | 72 | /** @var \Spatie\Period\Period $period */ 73 | $period = new $periodClass( 74 | start: $boundaries->realStart($includedStart, $precision), 75 | end: $boundaries->realEnd($includedEnd, $precision), 76 | precision: $precision, 77 | boundaries: $boundaries, 78 | ); 79 | 80 | return $period; 81 | } 82 | 83 | protected static function resolveDate( 84 | DateTimeInterface | string $date, 85 | ?string $format = null 86 | ): DateTimeImmutable { 87 | if ($date instanceof DateTimeImmutable) { 88 | return $date; 89 | } 90 | 91 | if ($date instanceof DateTime) { 92 | return DateTimeImmutable::createFromMutable($date); 93 | } 94 | 95 | if (! is_string($date)) { 96 | throw InvalidDate::forFormat($date, $format); 97 | } 98 | 99 | $format = static::resolveFormat($date, $format); 100 | 101 | $dateTime = DateTimeImmutable::createFromFormat($format, $date); 102 | 103 | if ($dateTime === false) { 104 | throw InvalidDate::forFormat($date, $format); 105 | } 106 | 107 | if (! str_contains($format, ' ')) { 108 | $dateTime = $dateTime->setTime(0, 0, 0); 109 | } 110 | 111 | return $dateTime; 112 | } 113 | 114 | protected static function resolveFormat( 115 | string $date, 116 | ?string $format 117 | ): string { 118 | if ($format !== null) { 119 | return $format; 120 | } 121 | 122 | if (str_contains($date, ' ')) { 123 | return 'Y-m-d H:i:s'; 124 | } 125 | 126 | return 'Y-m-d'; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/PeriodTraits/PeriodComparisons.php: -------------------------------------------------------------------------------- 1 | includedStart() < $date; 14 | } 15 | 16 | public function startsBeforeOrAt(DateTimeInterface $date): bool 17 | { 18 | return $this->includedStart() <= $date; 19 | } 20 | 21 | public function startsAfter(DateTimeInterface $date): bool 22 | { 23 | return $this->includedStart() > $date; 24 | } 25 | 26 | public function startsAfterOrAt(DateTimeInterface $date): bool 27 | { 28 | return $this->includedStart() >= $date; 29 | } 30 | 31 | public function startsAt(DateTimeInterface $date): bool 32 | { 33 | return $this->includedStart()->getTimestamp() 34 | === $this->precision->roundDate($date)->getTimestamp(); 35 | } 36 | 37 | public function endsBefore(DateTimeInterface $date): bool 38 | { 39 | return $this->includedEnd() < $this->precision->roundDate($date); 40 | } 41 | 42 | public function endsBeforeOrAt(DateTimeInterface $date): bool 43 | { 44 | return $this->includedEnd() <= $this->precision->roundDate($date); 45 | } 46 | 47 | public function endsAfter(DateTimeInterface $date): bool 48 | { 49 | return $this->includedEnd() > $this->precision->roundDate($date); 50 | } 51 | 52 | public function endsAfterOrAt(DateTimeInterface $date): bool 53 | { 54 | return $this->includedEnd() >= $this->precision->roundDate($date); 55 | } 56 | 57 | public function endsAt(DateTimeInterface $date): bool 58 | { 59 | return $this->includedEnd()->getTimestamp() 60 | === $this->precision->roundDate($date)->getTimestamp(); 61 | } 62 | 63 | public function overlapsWith(Period $period): bool 64 | { 65 | $this->ensurePrecisionMatches($period); 66 | 67 | if ($this->includedStart() > $period->includedEnd()) { 68 | return false; 69 | } 70 | 71 | if ($period->includedStart() > $this->includedEnd()) { 72 | return false; 73 | } 74 | 75 | return true; 76 | } 77 | 78 | public function touchesWith(Period $other): bool 79 | { 80 | $this->ensurePrecisionMatches($other); 81 | 82 | 83 | if ($this->includedEnd() < $other->includedStart()) { 84 | /* 85 | * [=======] 86 | * [======] 87 | */ 88 | $intervalBetween = $this->precision->roundDate($this->includedEnd()->add($this->interval)) 89 | ->diff( 90 | $other->precision->roundDate($other->includedStart()) 91 | ); 92 | } elseif ($this->includedStart() > $other->includedEnd()) { 93 | /* 94 | * [=====] 95 | * [======] 96 | */ 97 | $intervalBetween = $other->precision->roundDate($other->includedEnd()->add($other->interval)) 98 | ->diff( 99 | $this->precision->roundDate($this->includedStart()) 100 | ); 101 | } else { 102 | return false; 103 | } 104 | 105 | foreach (['y', 'm', 'd', 'h', 'i', 's'] as $field) { 106 | if ($intervalBetween->{$field} === 0) { 107 | continue; 108 | } 109 | 110 | return false; 111 | } 112 | 113 | return true; 114 | } 115 | 116 | public function contains(DateTimeInterface | Period $other): bool 117 | { 118 | if ($other instanceof Period) { 119 | return $this->includedStart() <= $other->includedStart() 120 | && $this->includedEnd() >= $other->includedEnd(); 121 | } 122 | 123 | $roundedDate = $this->precision->roundDate($other); 124 | 125 | return $roundedDate >= $this->includedStart() && $roundedDate <= $this->includedEnd(); 126 | } 127 | 128 | public function equals(Period $period): bool 129 | { 130 | $this->ensurePrecisionMatches($period); 131 | 132 | if ($period->includedStart()->getTimestamp() !== $this->includedStart()->getTimestamp()) { 133 | return false; 134 | } 135 | 136 | if ($period->includedEnd()->getTimestamp() !== $this->includedEnd()->getTimestamp()) { 137 | return false; 138 | } 139 | 140 | return true; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/PeriodTraits/PeriodGetters.php: -------------------------------------------------------------------------------- 1 | boundaries->startIncluded(); 19 | } 20 | 21 | public function isStartExcluded(): bool 22 | { 23 | return $this->boundaries->startExcluded(); 24 | } 25 | 26 | public function isEndIncluded(): bool 27 | { 28 | return $this->boundaries->endIncluded(); 29 | } 30 | 31 | public function isEndExcluded(): bool 32 | { 33 | return $this->boundaries->endExcluded(); 34 | } 35 | 36 | public function start(): DateTimeImmutable 37 | { 38 | return $this->start; 39 | } 40 | 41 | public function includedStart(): DateTimeImmutable 42 | { 43 | return $this->includedStart; 44 | } 45 | 46 | public function end(): DateTimeImmutable 47 | { 48 | return $this->end; 49 | } 50 | 51 | public function includedEnd(): DateTimeImmutable 52 | { 53 | return $this->includedEnd; 54 | } 55 | 56 | public function ceilingEnd(?Precision $precision = null): DateTimeImmutable 57 | { 58 | $precision ??= $this->precision; 59 | 60 | if ($precision->higherThan($this->precision)) { 61 | throw CannotCeilLowerPrecision::precisionIsLower($this->precision, $precision); 62 | } 63 | 64 | return $this->precision->ceilDate($this->includedEnd, $precision); 65 | } 66 | 67 | public function length(): int 68 | { 69 | // Length of month and year are not fixed, so we can't predict the length without iterate 70 | // TODO: maybe we can use cal_days_in_month ? 71 | if ($this->precision->equals(Precision::MONTH(), Precision::YEAR())) { 72 | return iterator_count($this); 73 | } 74 | 75 | if ($this->precision->equals(Precision::HOUR(), Precision::MINUTE(), Precision::SECOND())) { 76 | $length = abs($this->includedEnd()->getTimestamp() - $this->includedStart()->getTimestamp()); 77 | 78 | if ($this->precision->equals(Precision::SECOND())) { 79 | return $length + 1; 80 | } 81 | 82 | $length = floor($length / 60); 83 | 84 | if ($this->precision->equals(Precision::MINUTE())) { 85 | return $length + 1; 86 | } 87 | 88 | return floor($length / 60) + 1; 89 | } 90 | 91 | return $this->includedStart()->diff($this->includedEnd())->days + 1; 92 | } 93 | 94 | public function duration(): PeriodDuration 95 | { 96 | return $this->duration; 97 | } 98 | 99 | public function precision(): Precision 100 | { 101 | return $this->precision; 102 | } 103 | 104 | public function boundaries(): Boundaries 105 | { 106 | return $this->boundaries; 107 | } 108 | 109 | public function asString(): string 110 | { 111 | if (! isset($this->asString)) { 112 | $this->asString = $this->resolveString(); 113 | } 114 | 115 | return $this->asString; 116 | } 117 | 118 | private function resolveString(): string 119 | { 120 | $string = ''; 121 | 122 | if ($this->isStartIncluded()) { 123 | $string .= '['; 124 | } else { 125 | $string .= '('; 126 | } 127 | 128 | $string .= $this->start()->format($this->precision->dateFormat()); 129 | 130 | $string .= ','; 131 | 132 | $string .= $this->end()->format($this->precision->dateFormat()); 133 | 134 | if ($this->isEndIncluded()) { 135 | $string .= ']'; 136 | } else { 137 | $string .= ')'; 138 | } 139 | 140 | return $string; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/PeriodTraits/PeriodOperations.php: -------------------------------------------------------------------------------- 1 | ensurePrecisionMatches($period); 15 | 16 | if ($this->overlapsWith($period)) { 17 | return null; 18 | } 19 | 20 | if ($this->touchesWith($period)) { 21 | return null; 22 | } 23 | 24 | if ($this->includedStart() >= $period->includedEnd()) { 25 | return static::make( 26 | $period->includedEnd()->add($this->interval), 27 | $this->includedStart()->sub($this->interval), 28 | $this->precision() 29 | ); 30 | } 31 | 32 | return static::make( 33 | $this->includedEnd()->add($this->interval), 34 | $period->includedStart()->sub($this->interval), 35 | $this->precision() 36 | ); 37 | } 38 | 39 | public function overlap(Period ...$others): ?static 40 | { 41 | if (count($others) === 0) { 42 | return null; 43 | } elseif (count($others) > 1) { 44 | return $this->overlapAll(...$others); 45 | } else { 46 | $other = $others[0]; 47 | } 48 | 49 | $this->ensurePrecisionMatches($other); 50 | 51 | $includedStart = $this->includedStart() > $other->includedStart() 52 | ? $this->includedStart() 53 | : $other->includedStart(); 54 | 55 | $includedEnd = $this->includedEnd() < $other->includedEnd() 56 | ? $this->includedEnd() 57 | : $other->includedEnd(); 58 | 59 | if ($includedStart > $includedEnd) { 60 | return null; 61 | } 62 | 63 | return PeriodFactory::makeWithBoundaries( 64 | static::class, 65 | $includedStart, 66 | $includedEnd, 67 | $this->precision(), 68 | $this->boundaries(), 69 | ); 70 | } 71 | 72 | protected function overlapAll(Period ...$periods): ?static 73 | { 74 | $overlap = clone $this; 75 | 76 | if (! count($periods)) { 77 | return $overlap; 78 | } 79 | 80 | foreach ($periods as $period) { 81 | $overlap = $overlap->overlap($period); 82 | 83 | if ($overlap === null) { 84 | return null; 85 | } 86 | } 87 | 88 | return $overlap; 89 | } 90 | 91 | /** 92 | * @param \Spatie\Period\Period ...$others 93 | * 94 | * @return \Spatie\Period\PeriodCollection|static[] 95 | */ 96 | public function overlapAny(Period ...$others): PeriodCollection 97 | { 98 | $overlapCollection = new PeriodCollection(); 99 | 100 | foreach ($others as $period) { 101 | $overlap = $this->overlap($period); 102 | 103 | if ($overlap === null) { 104 | continue; 105 | } 106 | 107 | $overlapCollection[] = $overlap; 108 | } 109 | 110 | return $overlapCollection; 111 | } 112 | 113 | /** 114 | * @param \Spatie\Period\Period|iterable $other 115 | * 116 | * @return \Spatie\Period\PeriodCollection|static[] 117 | */ 118 | public function subtract(Period ...$others): PeriodCollection 119 | { 120 | if (count($others) === 0) { 121 | return PeriodCollection::make($this); 122 | } elseif (count($others) > 1) { 123 | return $this->subtractAll(...$others); 124 | } else { 125 | $other = $others[0]; 126 | } 127 | 128 | $this->ensurePrecisionMatches($other); 129 | 130 | $collection = new PeriodCollection(); 131 | 132 | if (! $this->overlapsWith($other)) { 133 | $collection[] = $this; 134 | 135 | return $collection; 136 | } 137 | 138 | if ($this->includedStart() < $other->includedStart()) { 139 | $collection[] = PeriodFactory::makeWithBoundaries( 140 | static::class, 141 | $this->includedStart(), 142 | $other->includedStart()->sub($this->interval), 143 | $this->precision(), 144 | $this->boundaries(), 145 | ); 146 | } 147 | 148 | if ($this->includedEnd() > $other->includedEnd()) { 149 | $collection[] = PeriodFactory::makeWithBoundaries( 150 | static::class, 151 | $other->includedEnd()->add($this->interval), 152 | $this->includedEnd(), 153 | $this->precision(), 154 | $this->boundaries(), 155 | ); 156 | } 157 | 158 | return $collection; 159 | } 160 | 161 | protected function subtractAll(Period ...$others): PeriodCollection 162 | { 163 | $subtractions = []; 164 | 165 | foreach ($others as $other) { 166 | $subtractions[] = $this->subtract($other); 167 | } 168 | 169 | return (new PeriodCollection($this))->overlapAll(...$subtractions); 170 | } 171 | 172 | /** 173 | * @param \Spatie\Period\Period $other 174 | * 175 | * @return \Spatie\Period\PeriodCollection|static[] 176 | */ 177 | public function diffSymmetric(Period $other): PeriodCollection 178 | { 179 | $this->ensurePrecisionMatches($other); 180 | 181 | $periodCollection = new PeriodCollection(); 182 | 183 | if (! $this->overlapsWith($other)) { 184 | $periodCollection[] = clone $this; 185 | $periodCollection[] = clone $other; 186 | 187 | return $periodCollection; 188 | } 189 | 190 | $boundaries = (new PeriodCollection($this, $other))->boundaries(); 191 | 192 | $overlap = $this->overlap($other); 193 | 194 | return $boundaries->subtract($overlap); 195 | } 196 | 197 | public function renew(): static 198 | { 199 | $length = $this->includedStart->diff($this->includedEnd); 200 | 201 | $start = $this->includedEnd->add($this->interval); 202 | 203 | $end = $start->add($length); 204 | 205 | return static::make($start, $end, $this->precision, $this->boundaries); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Precision.php: -------------------------------------------------------------------------------- 1 | self::YEAR(), 44 | 2 => self::MONTH(), 45 | 3 => self::DAY(), 46 | 4 => self::HOUR(), 47 | 5 => self::MINUTE(), 48 | 6 => self::SECOND(), 49 | }; 50 | } 51 | 52 | public static function YEAR(): self 53 | { 54 | return new self(self::YEAR); 55 | } 56 | 57 | public static function MONTH(): self 58 | { 59 | return new self(self::MONTH); 60 | } 61 | 62 | public static function DAY(): self 63 | { 64 | return new self(self::DAY); 65 | } 66 | 67 | public static function HOUR(): self 68 | { 69 | return new self(self::HOUR); 70 | } 71 | 72 | public static function MINUTE(): self 73 | { 74 | return new self(self::MINUTE); 75 | } 76 | 77 | public static function SECOND(): self 78 | { 79 | return new self(self::SECOND); 80 | } 81 | 82 | public function interval(): DateInterval 83 | { 84 | $interval = match ($this->mask) { 85 | self::SECOND => 'PT1S', 86 | self::MINUTE => 'PT1M', 87 | self::HOUR => 'PT1H', 88 | self::DAY => 'P1D', 89 | self::MONTH => 'P1M', 90 | self::YEAR => 'P1Y', 91 | }; 92 | 93 | return new DateInterval($interval); 94 | } 95 | 96 | public function intervalName(): string 97 | { 98 | return match ($this->mask) { 99 | self::YEAR => 'y', 100 | self::MONTH => 'm', 101 | self::DAY => 'd', 102 | self::HOUR => 'h', 103 | self::MINUTE => 'i', 104 | self::SECOND => 's', 105 | }; 106 | } 107 | 108 | public function roundDate(DateTimeInterface $date): DateTimeImmutable 109 | { 110 | [$year, $month, $day, $hour, $minute, $second] = explode(' ', $date->format('Y m d H i s')); 111 | 112 | $month = (self::MONTH & $this->mask) === self::MONTH ? $month : '01'; 113 | $day = (self::DAY & $this->mask) === self::DAY ? $day : '01'; 114 | $hour = (self::HOUR & $this->mask) === self::HOUR ? $hour : '00'; 115 | $minute = (self::MINUTE & $this->mask) === self::MINUTE ? $minute : '00'; 116 | $second = (self::SECOND & $this->mask) === self::SECOND ? $second : '00'; 117 | 118 | return DateTimeImmutable::createFromFormat( 119 | 'Y m d H i s', 120 | implode(' ', [$year, $month, $day, $hour, $minute, $second]), 121 | $date->getTimezone() 122 | ); 123 | } 124 | 125 | public function ceilDate(DateTimeInterface $date, Precision $precision): DateTimeImmutable 126 | { 127 | [$year, $month, $day, $hour, $minute, $second] = explode(' ', $date->format('Y m d H i s')); 128 | 129 | $month = (self::MONTH & $precision->mask) === self::MONTH ? $month : '12'; 130 | $day = (self::DAY & $precision->mask) === self::DAY ? $day : cal_days_in_month(CAL_GREGORIAN, $month, $year); 131 | $hour = (self::HOUR & $precision->mask) === self::HOUR ? $hour : '23'; 132 | $minute = (self::MINUTE & $precision->mask) === self::MINUTE ? $minute : '59'; 133 | $second = (self::SECOND & $precision->mask) === self::SECOND ? $second : '59'; 134 | 135 | return DateTimeImmutable::createFromFormat( 136 | 'Y m d H i s', 137 | implode(' ', [$year, $month, $day, $hour, $minute, $second]), 138 | $date->getTimezone() 139 | ); 140 | } 141 | 142 | public function equals(Precision ...$others): bool 143 | { 144 | foreach ($others as $other) { 145 | if ($this->mask !== $other->mask) { 146 | continue; 147 | } 148 | 149 | return true; 150 | } 151 | 152 | return false; 153 | } 154 | 155 | public function increment(DateTimeImmutable $date): DateTimeImmutable 156 | { 157 | return $this->roundDate($date->add($this->interval())); 158 | } 159 | 160 | public function decrement(DateTimeImmutable $date): DateTimeImmutable 161 | { 162 | return $this->roundDate($date->sub($this->interval())); 163 | } 164 | 165 | public function higherThan(Precision $other): bool 166 | { 167 | return strlen($this->dateFormat()) > strlen($other->dateFormat()); 168 | } 169 | 170 | public function dateFormat(): string 171 | { 172 | return match ($this->mask) { 173 | Precision::SECOND => 'Y-m-d H:i:s', 174 | Precision::MINUTE => 'Y-m-d H:i', 175 | Precision::HOUR => 'Y-m-d H', 176 | Precision::DAY => 'Y-m-d', 177 | Precision::MONTH => 'Y-m', 178 | Precision::YEAR => 'Y', 179 | }; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Visualizer.php: -------------------------------------------------------------------------------- 1 | options = $options; 26 | } 27 | 28 | /** 29 | * Builds a string to visualize one or more 30 | * periods and/or collections in a more 31 | * human readable / parsable manner. 32 | * 33 | * Keys are used as identifiers in the output 34 | * and the periods are represented with bars. 35 | * 36 | * This visualizer is capable of generating 37 | * output like the following: 38 | * 39 | * A [========] 40 | * B [==] 41 | * C [=====] 42 | * CURRENT [===============] 43 | * OVERLAP [=] [==] [=] 44 | * 45 | * @param array|Period[]|PeriodCollection[] $blocks 46 | * @return string 47 | */ 48 | public function visualize(array $blocks): string 49 | { 50 | $matrix = $this->matrix($blocks); 51 | 52 | $nameLength = max(...array_map('strlen', array_keys($matrix))); 53 | 54 | $lines = []; 55 | 56 | foreach ($matrix as $name => $row) { 57 | $lines[] = vsprintf('%s %s', [ 58 | str_pad($name, $nameLength, ' '), 59 | $this->toBars($row), 60 | ]); 61 | } 62 | 63 | return implode("\n", $lines); 64 | } 65 | 66 | /** 67 | * Build a 2D table such that: 68 | * - There's one row for every block. 69 | * - There's one column for every unit of width. 70 | * - Each cell is true when a period is active for that unit. 71 | * - Each cell is false when a period is not active for that unit. 72 | * 73 | * @param array $blocks 74 | * @return array 75 | */ 76 | private function matrix(array $blocks): array 77 | { 78 | $width = $this->options['width']; 79 | 80 | $matrix = array_fill(0, count($blocks), array_fill(0, $width, false)); 81 | $matrix = array_combine(array_keys($blocks), array_values($matrix)); 82 | 83 | $bounds = $this->bounds($blocks); 84 | 85 | foreach ($blocks as $name => $block) { 86 | if ($block instanceof Period) { 87 | $matrix[$name] = $this->populateRow($matrix[$name], $block, $bounds); 88 | } elseif ($block instanceof PeriodCollection) { 89 | foreach ($block as $period) { 90 | $matrix[$name] = $this->populateRow($matrix[$name], $period, $bounds); 91 | } 92 | } 93 | } 94 | 95 | return $matrix; 96 | } 97 | 98 | /** 99 | * Get the start / end coordinates for a given period. 100 | * 101 | * @param Period $period 102 | * @param Period $bounds 103 | * @param int $width 104 | * @return array 105 | */ 106 | private function coords(Period $period, Period $bounds, int $width): array 107 | { 108 | $boundsStart = $bounds->start()->getTimestamp(); 109 | $boundsEnd = $bounds->end()->getTimestamp(); 110 | $boundsLength = $boundsEnd - $boundsStart; 111 | 112 | // Get the bounds 113 | $start = $period->start()->getTimestamp() - $boundsStart; 114 | $end = $period->end()->getTimestamp() - $boundsStart; 115 | 116 | // Rescale from timestamps to width units 117 | $start *= $width / $boundsLength; 118 | $end *= $width / $boundsLength; 119 | 120 | // Cap at integer intervals 121 | $start = floor($start); 122 | $end = ceil($end); 123 | 124 | return [$start, $end]; 125 | } 126 | 127 | /** 128 | * Populate a row with true values 129 | * where periods are active. 130 | * 131 | * @param array $row 132 | * @param Period $period 133 | * @param Period $bounds 134 | * @return array 135 | */ 136 | private function populateRow(array $row, Period $period, Period $bounds): array 137 | { 138 | $width = $this->options['width']; 139 | 140 | [$startIndex, $endIndex] = $this->coords($period, $bounds, $width); 141 | 142 | for ($i = 0; $i < $width; $i++) { 143 | if ($startIndex <= $i && $i < $endIndex) { 144 | $row[$i] = true; 145 | } 146 | } 147 | 148 | return $row; 149 | } 150 | 151 | /** 152 | * Get the bounds encompassing all visualized periods. 153 | * 154 | * @param array $blocks 155 | * @return Period|null 156 | */ 157 | private function bounds(array $blocks): ?Period 158 | { 159 | $periods = new PeriodCollection(); 160 | 161 | foreach ($blocks as $block) { 162 | if ($block instanceof Period) { 163 | $periods[] = $block; 164 | } elseif ($block instanceof PeriodCollection) { 165 | foreach ($block as $period) { 166 | $periods[] = $period; 167 | } 168 | } 169 | } 170 | 171 | return $periods->boundaries(); 172 | } 173 | 174 | /** 175 | * Turn a series of true/false values into bars 176 | * representing the start/end of periods. 177 | * 178 | * @param array $row 179 | * @return string 180 | */ 181 | private function toBars(array $row): string 182 | { 183 | $tmp = ''; 184 | 185 | for ($i = 0, $l = count($row); $i < $l; $i++) { 186 | $prev = $row[$i - 1] ?? null; 187 | $curr = $row[$i]; 188 | $next = $row[$i + 1] ?? null; 189 | 190 | // Small state machine to build the string 191 | switch (true) { 192 | // The current period is only one unit long so display a "=" 193 | case $curr && $curr !== $prev && $curr !== $next: 194 | $tmp .= '='; 195 | 196 | break; 197 | 198 | // We've hit the start of a period 199 | case $curr && $curr !== $prev && $curr === $next: 200 | $tmp .= '['; 201 | 202 | break; 203 | 204 | // We've hit the end of the period 205 | case $curr && $curr !== $next: 206 | $tmp .= ']'; 207 | 208 | break; 209 | 210 | // We're adding segments to the current period 211 | case $curr && $curr === $prev: 212 | $tmp .= '='; 213 | 214 | break; 215 | 216 | // Otherwise it's just empty space 217 | default: 218 | $tmp .= ' '; 219 | 220 | break; 221 | } 222 | } 223 | 224 | return $tmp; 225 | } 226 | } 227 | --------------------------------------------------------------------------------