├── .codeclimate.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── changelog.md ├── composer.json ├── infection.json.dist ├── phpunit.xml.dist ├── readme.md ├── src ├── DateTimePeriod.php └── Exceptions │ ├── NegativeDateTimePeriod.php │ └── UTCOffsetMismatch.php └── tests └── unit └── DateTimePeriodTest.php /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - php 8 | phpcodesniffer: 9 | enabled: true 10 | config: 11 | ignore_warnings: true 12 | ratings: 13 | paths: 14 | - "**.php" 15 | exclude_paths: 16 | - tests/ 17 | - vendor/ 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # for common files/dirs (eg.: .idea) create a global gitignore file (eg. ~/.gitignore_global) and run 2 | # git config --global core.excludesfile ~/.gitignore_global 3 | composer.lock 4 | phpunit.xml 5 | vendor/ 6 | infection.log 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: php 4 | 5 | php: 6 | - 7.1 7 | - 7.2 8 | 9 | before_install: 10 | - travis_retry composer self-update 11 | 12 | install: 13 | - travis_retry composer install --no-interaction --prefer-dist 14 | 15 | before_script: 16 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 17 | - chmod +x ./cc-test-reporter 18 | - ./cc-test-reporter before-build 19 | - echo 'date.timezone = "Europe/London"' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 20 | 21 | script: 22 | - vendor/bin/phpcs --standard=PSR2 --warning-severity=0 src 23 | - vendor/bin/phpstan analyse -l 7 src 24 | - vendor/bin/phpunit --coverage-clover=clover.xml 25 | 26 | after_script: 27 | - bash <(curl -s https://codecov.io/bash) 28 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Zsolt Szende 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.1] - 2018-04-09 8 | ### Changed 9 | * Renamed the misleading `TimeZoneMismatch` exception to `UTCOffsetMismatch`. 10 | 11 | ### Removed 12 | * The interval property. The reason was that it can trivially be derived from the start/end instants and having it made the data type smart which created problems. In general keeping the data type dumb is preferred. 13 | 14 | ## [1.0.0] - 2017-10-10 15 | ### Added 16 | * Initial release 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwm/datetime-period", 3 | "description": "An implementation of the datetime period type for working with temporal intervals", 4 | "type": "library", 5 | "keywords": ["datetime-period", "interval-algebra", "temporal-logic"], 6 | "homepage": "https://github.com/pwm/datetime-period", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Zsolt Szende", 11 | "email": "zs@szende.me" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=7.1.0" 16 | }, 17 | "require-dev": { 18 | "squizlabs/php_codesniffer": "^3.0", 19 | "phpstan/phpstan": "^0.7.0", 20 | "phpunit/phpunit": "^6.1", 21 | "infection/infection": "^0.8.2" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Pwm\\DateTimePeriod\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Pwm\\DateTimePeriod\\": "tests/unit/" 31 | } 32 | }, 33 | "extra": { 34 | "branch-alias": { 35 | "dev-master": "1.0-dev" 36 | } 37 | }, 38 | "scripts": { 39 | "phpcs": "vendor/bin/phpcs --standard=PSR2 --warning-severity=0 src", 40 | "phpstan": "vendor/bin/phpstan analyse -l 7 src", 41 | "infection": "vendor/bin/infection --log-verbosity=2 --only-covered" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 4, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "infection.log" 10 | } 11 | } -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/unit 13 | 14 | 15 | 16 | 17 | ./src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # DateTimePeriod 2 | 3 | [![Build Status](https://travis-ci.org/pwm/datetime-period.svg?branch=master)](https://travis-ci.org/pwm/datetime-period) 4 | [![codecov](https://codecov.io/gh/pwm/datetime-period/branch/master/graph/badge.svg)](https://codecov.io/gh/pwm/datetime-period) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/25356a7f11c642ee8ac5/maintainability)](https://codeclimate.com/github/pwm/datetime-period/maintainability) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/25356a7f11c642ee8ac5/test_coverage)](https://codeclimate.com/github/pwm/datetime-period/test_coverage) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 8 | 9 | An implementation of the datetime period type for working with temporal intervals. The library includes the full set of relations on intervals defined by [Allen's Interval Algebra](https://www.ics.uci.edu/~alspaugh/cls/shr/allen.html). For further information see the "Usage" and "How it works" paragraphs. 10 | 11 | ## Table of Contents 12 | 13 | * [Requirements](#requirements) 14 | * [Installation](#installation) 15 | * [Usage](#usage) 16 | * [How it works](#how-it-works) 17 | * [Tests](#tests) 18 | * [Changelog](#changelog) 19 | * [Todo](#todo) 20 | * [Licence](#licence) 21 | 22 | ## Requirements 23 | 24 | PHP 7.1+ 25 | 26 | ## Installation 27 | 28 | $ composer require pwm/datetime-period 29 | 30 | ## Usage 31 | 32 | ##### Creation: 33 | 34 | ```php 35 | $start = new DateTimeImmutable('2010-10-10T10:10:10+00:00'); 36 | $end = new DateTimeImmutable('2011-11-11T11:11:11+00:00'); 37 | $period = new DateTimePeriod($start, $end); 38 | 39 | // Start and end instants (see the definition of instant under "How it works") 40 | $start = $period->getStart(); // DateTimeImmutable('2010-10-10T10:10:10+00:00') 41 | $end = $period->getEnd(); // DateTimeImmutable('2011-11-11T11:11:11+00:00') 42 | ``` 43 | 44 | ##### Restrictions: 45 | 46 | ```php 47 | // Throws TimeZoneMismatch exception 48 | new DateTimePeriod( 49 | new DateTimeImmutable('2017-10-10T10:10:10+02:00'), 50 | new DateTimeImmutable('2017-10-10T10:10:10-05:00') 51 | ); 52 | 53 | // Throws NegativeDateTimePeriod exception 54 | new DateTimePeriod( 55 | new DateTimeImmutable('+1 day'), 56 | new DateTimeImmutable('-1 day') 57 | ); 58 | ``` 59 | 60 | ##### The full set of relations between 2 periods: 61 | 62 | ```php 63 | $a = new DateTimePeriod(new DateTimeImmutable('...'), new DateTimeImmutable('...')); 64 | $b = new DateTimePeriod(new DateTimeImmutable('...'), new DateTimeImmutable('...')); 65 | 66 | // |--a--| 67 | // |--b--| 68 | $a->precedes($b); 69 | 70 | // |--a--| 71 | // |--b--| 72 | $a->meets($b); 73 | 74 | // |--a--| 75 | // |--b--| 76 | $a->overlaps($b); 77 | 78 | // |----a----| 79 | // |--b--| 80 | $a->finishedBy($b); 81 | 82 | // |----a----| 83 | // |--b--| 84 | $a->contains($b); 85 | 86 | // |--a--| 87 | // |----b----| 88 | $a->starts($b); 89 | 90 | // |--a--| 91 | // |--b--| 92 | $a->equals($b); 93 | 94 | // |----a----| 95 | // |--b--| 96 | $a->startedBy($b); 97 | 98 | // |--a--| 99 | // |----b----| 100 | $a->during($b); 101 | 102 | // |--a--| 103 | // |----b----| 104 | $a->finishes($b); 105 | 106 | // |--a--| 107 | // |--b--| 108 | $a->overlappedBy($b); 109 | 110 | // |--a--| 111 | // |--b--| 112 | $a->metBy($b); 113 | 114 | // |--a--| 115 | // |--b--| 116 | $a->precededBy($b); 117 | ``` 118 | 119 | ##### Extensibility: 120 | 121 | The `DateTimePeriod` object itself is immutable, meaning that once created you can't change the state of the object, ie. the values of its properties. However the properties have been defined as `protected` so that you can subclass the type in your project if the need arises. 122 | 123 | ##### Working with different granularities: 124 | 125 | The 2 periods below meet on a timeline with hour granularity but does not meet on a more fine-grained timeline with minute granularity. 126 | 127 | ```php 128 | $aStart = '2017-01-01T12:12:09.829462+00:00'; 129 | $aEnd = '2017-01-01T14:23:34.534678+00:00'; 130 | $bStart = '2017-01-01T14:41:57.657388+00:00'; 131 | $bEnd = '2017-01-01T16:19:03.412832+00:00'; 132 | 133 | $hourGranule = 'Y-m-d\TH'; 134 | $a = new DateTimePeriod( 135 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($aStart))->format($hourGranule)), 136 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($aEnd))->format($hourGranule)) 137 | ); 138 | $b = new DateTimePeriod( 139 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($bStart))->format($hourGranule)), 140 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($bEnd))->format($hourGranule)) 141 | ); 142 | assert($a->meets($b) === true); // a meets b by the hour granule 143 | 144 | $minuteGranule = 'Y-m-d\TH:i'; 145 | $a = new DateTimePeriod( 146 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($aStart))->format($minuteGranule)), 147 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($aEnd))->format($minuteGranule)) 148 | ); 149 | 150 | $b = new DateTimePeriod( 151 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($bStart))->format($minuteGranule)), 152 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($bEnd))->format($minuteGranule)) 153 | ); 154 | assert($a->meets($b) === false); // a does not meet b by the minute granule 155 | ``` 156 | 157 | ##### Miscellaneous: 158 | 159 | This is a list of small helper methods that can save you time when you need their specific functionality. 160 | 161 | 1. Get the number of days in a period: 162 | 163 | ```php 164 | $start = new DateTimeImmutable('2016-01-01T11:11:11+00:00'); 165 | $end = new DateTimeImmutable('2018-01-01T11:11:11+00:00'); 166 | $period = new DateTimePeriod($start, $end); 167 | assert(366 + 365 === $period->getNumberOfDays()); // 2016 was a leap year 168 | ``` 169 | 170 | ## How it works 171 | 172 | In order to be able to talk about periods first let's agree on the following definitions: 173 | 174 | #### Definitions 175 | 176 | ##### 1. Instant 177 | An anchor, ie. discrete point, on the timeline. The most basic temporal type. A "true" time instant is theoretical like a point on a continuous geometrical line. A representation of an instant, however, always has a duration, called a granule. We can thus represent the same instant using various discreet timelines of different granularities. Eg. "2017-10-10" and "2017-10-10 10:10:10" could represent the same instant. 178 | 179 | ##### 2. Interval 180 | An unanchored, directed portion of the timeline. Unanchored means it has no absolute relation to the timeline. Examples are "2 weeks" or "1 day, 2 hours and 3 minutes". Directed means it is perfectly valid to say "-3 days". 181 | 182 | ##### 3. Period 183 | An anchored interval on the timeline. There are several possible representations, the most common being a pair of ordered instants of identical granularity. Depending on the representation the interval of a period can be open or closed on both its start and end. A common way is to use a closed-open interval, ie. [start, end), which helps simplifying calculations. Eg. the period ["2017-10-10", "2017-11-11") includes the instant "2017-10-10" but excludes the instant "2017-11-11". 184 | 185 | We arrived to the definition of a period. Now on to... 186 | 187 | #### Relations 188 | 189 | Defining relations on periods is somewhat complex as there is no [total order](https://en.wikipedia.org/wiki/Total_order). In 1983 James F. Allen wrote a paper in which he defined 13 jointly exhaustive and pairwise disjoint binary relations on intervals, meaning that any 2 intervals are related exactly one way. You can see each of the 13 relations above, in the "Usage" section. These relations and the operations on them form what is referred to as [Allen's interval algebra](https://www.ics.uci.edu/~alspaugh/cls/shr/allen.html). 190 | 191 | #### Complexities 192 | 193 | Dealing with calendrical time is difficult with many peculiarities. This, in turn, means that dealing with derivative entities, like `DateTimePeriod` is complex as well. 194 | 195 | Here is an example involving timezones: 196 | 197 | If you try to create a one year `DateTimePeriod` starting from `2018-03-26T08:00:00` and ending at `2019-03-26T08:00:00` for the timezone `Europe/London` it would fail with a `UTCOffsetMismatch` exception. This is because for the start instant the timezone `Europe/London` equals to `BST` (which equals to `UTC+01:00`) while for the end instant `Europe/London` equals to `GMT` (which equals to `UTC+00:00`). This is because daylight saving time happens on different days in different years. 198 | 199 | If you create the above period using UTC offsets, ie. from `2018-03-26T08:00:00+01:00` to `2019-03-26T08:00:00+01:00` that would not throw a `UTCOffsetMismatch`, however the end instant will not be a valid `Europe/London` datetime so you would have to calculate the correct time for `Europe/London` from your `UTC+01:00` offset. 200 | 201 | The supplied `DateTimePeriod::getUtcOffset()` function can help ease this problem by mapping your current timezone to its UTC offset. 202 | 203 | ## Tests 204 | 205 | $ vendor/bin/phpunit 206 | $ composer phpcs 207 | $ composer phpstan 208 | $ composer infection 209 | 210 | ## Changelog 211 | 212 | [Click here](changelog.md) 213 | 214 | ## Todo 215 | 216 | * Work out a more user friendly solution for the UTC offset / timezone problem. 217 | 218 | ## Licence 219 | 220 | [MIT](LICENSE) 221 | -------------------------------------------------------------------------------- /src/DateTimePeriod.php: -------------------------------------------------------------------------------- 1 | start = $start; 29 | $this->end = $end; 30 | } 31 | 32 | /** 33 | * [a1, a2) precedes [b1, b2): a2 < b1 34 | * 35 | * |--a--| 36 | * |--b--| 37 | */ 38 | public function precedes(DateTimePeriod $period): bool 39 | { 40 | return $this->getEnd() < $period->getStart(); 41 | } 42 | 43 | /** 44 | * [a1, a2) meets [b1, b2): a2 = b1 45 | * 46 | * |--a--| 47 | * |--b--| 48 | */ 49 | public function meets(DateTimePeriod $period): bool 50 | { 51 | return $this->getEnd() == $period->getStart(); 52 | } 53 | 54 | /** 55 | * [a1, a2) overlaps [b1, b2): a1 < b1 and a2 < b2 and b1 < a2 56 | * 57 | * |--a--| 58 | * |--b--| 59 | */ 60 | public function overlaps(DateTimePeriod $period): bool 61 | { 62 | return 63 | $this->getStart() < $period->getStart() && 64 | $this->getEnd() < $period->getEnd() && 65 | $period->getStart() < $this->getEnd(); 66 | } 67 | 68 | /** 69 | * [a1, a2) finishedBy [b1, b2): a1 < b1 and a2 = b2 70 | * 71 | * |----a----| 72 | * |--b--| 73 | */ 74 | public function finishedBy(DateTimePeriod $period): bool 75 | { 76 | return 77 | $this->getStart() < $period->getStart() && 78 | $this->getEnd() == $period->getEnd(); 79 | } 80 | 81 | /** 82 | * [a1, a2) contains [b1, b2): a1 < b1 and b2 < a2 83 | * 84 | * |----a----| 85 | * |--b--| 86 | */ 87 | public function contains(DateTimePeriod $period): bool 88 | { 89 | return 90 | $this->getStart() < $period->getStart() && 91 | $period->getEnd() < $this->getEnd(); 92 | } 93 | 94 | /** 95 | * [a1, a2) starts [b1, b2): a1 = b1 and a2 < b2 96 | * 97 | * |--a--| 98 | * |----b----| 99 | */ 100 | public function starts(DateTimePeriod $period): bool 101 | { 102 | return 103 | $this->getStart() == $period->getStart() && 104 | $this->getEnd() < $period->getEnd(); 105 | } 106 | 107 | /** 108 | * [a1, a2) equals [b1, b2): a1 = b1 && a2 = b2 109 | * 110 | * |--a--| 111 | * |--b--| 112 | */ 113 | public function equals(DateTimePeriod $period): bool 114 | { 115 | return 116 | $this->getStart() == $period->getStart() && 117 | $this->getEnd() == $period->getEnd(); 118 | } 119 | 120 | /** 121 | * [a1, a2) startedBy [b1, b2): a1 = b1 and b2 < a2 122 | * 123 | * |----a----| 124 | * |--b--| 125 | */ 126 | public function startedBy(DateTimePeriod $period): bool 127 | { 128 | return 129 | $this->getStart() == $period->getStart() && 130 | $period->getEnd() < $this->getEnd(); 131 | } 132 | 133 | /** 134 | * [a1, a2) during [b1, b2): b1 < a1 and a2 < b2 135 | * 136 | * |--a--| 137 | * |----b----| 138 | */ 139 | public function during(DateTimePeriod $period): bool 140 | { 141 | return 142 | $period->getStart() < $this->getStart() && 143 | $this->getEnd() < $period->getEnd(); 144 | } 145 | 146 | /** 147 | * [a1, a2) finishes [b1, b2): b1 < a1 and a2 = b2 148 | * 149 | * |--a--| 150 | * |----b----| 151 | */ 152 | public function finishes(DateTimePeriod $period): bool 153 | { 154 | return 155 | $period->getStart() < $this->getStart() && 156 | $this->getEnd() == $period->getEnd(); 157 | } 158 | 159 | /** 160 | * [a1, a2) overlappedBy [b1, b2): b1 < a1 and b2 < a2 and a1 < b2 161 | * 162 | * |--a--| 163 | * |--b--| 164 | */ 165 | public function overlappedBy(DateTimePeriod $period): bool 166 | { 167 | return 168 | $period->getStart() < $this->getStart() && 169 | $period->getEnd() < $this->getEnd() && 170 | $this->getStart() < $period->getEnd(); 171 | } 172 | 173 | /** 174 | * [a1, a2) metBy [b1, b2): b2 = a1 175 | * 176 | * |--a--| 177 | * |--b--| 178 | */ 179 | public function metBy(DateTimePeriod $period): bool 180 | { 181 | return $period->getEnd() == $this->getStart(); 182 | } 183 | 184 | /** 185 | * [a1, a2) precededBy [b1, b2): b2 < a1 186 | * 187 | * |--a--| 188 | * |--b--| 189 | */ 190 | public function precededBy(DateTimePeriod $period): bool 191 | { 192 | return $period->getEnd() < $this->getStart(); 193 | } 194 | 195 | public function getStart(): DateTimeImmutable 196 | { 197 | return $this->start; 198 | } 199 | 200 | public function getEnd(): DateTimeImmutable 201 | { 202 | return $this->end; 203 | } 204 | 205 | public function getNumberOfDays(): int 206 | { 207 | return (int)$this->getEnd()->diff($this->getStart())->format('%a'); 208 | } 209 | 210 | public static function getUtcOffset(DateTimeImmutable $datetime): string 211 | { 212 | $utcOffset = $datetime 213 | ->getTimezone() 214 | ->getOffset(new DateTime($datetime->format('Y-m-d H:i:s'), new DateTimeZone('UTC'))); 215 | $hour = floor(abs($utcOffset) / self::SECONDS_IN_HOUR); 216 | $minute = abs($utcOffset) % self::SECONDS_IN_HOUR / self::SECONDS_IN_MINUTE; 217 | return sprintf('%s%02s:%02s', $utcOffset >= 0 ? '+' : '-', $hour, $minute); 218 | } 219 | 220 | private static function ensureUTCOffsetsMatch(DateTimeImmutable $start, DateTimeImmutable $end): void 221 | { 222 | if ($start->getOffset() !== $end->getOffset()) { 223 | throw new UTCOffsetMismatch( 224 | sprintf( 225 | 'Start instant UTC offset %s and end instant UTC offset %s differ.', 226 | $start->getOffset(), 227 | $end->getOffset() 228 | ) 229 | ); 230 | } 231 | } 232 | 233 | private static function ensureStartIsBeforeEnd(DateTimeImmutable $start, DateTimeImmutable $end): void 234 | { 235 | if ($start > $end) { 236 | throw new NegativeDateTimePeriod( 237 | sprintf( 238 | 'Start date "%s" cannot be after end date "%s".', 239 | $start->format(DATE_ATOM), 240 | $end->format(DATE_ATOM) 241 | ) 242 | ); 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/Exceptions/NegativeDateTimePeriod.php: -------------------------------------------------------------------------------- 1 | getStart()); 40 | self::assertEquals($end, $period->getEnd()); 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function it_creates_from_equal_start_and_end_instants(): void 47 | { 48 | $ts = new DateTimeImmutable(); 49 | 50 | $period = new DateTimePeriod($ts, $ts); 51 | 52 | self::assertInstanceOf(DateTimePeriod::class, $period); 53 | self::assertEquals($ts, $period->getStart()); 54 | self::assertEquals($ts, $period->getEnd()); 55 | } 56 | 57 | /** 58 | * @test 59 | * @expectedException \Pwm\DateTimePeriod\Exceptions\UTCOffsetMismatch 60 | */ 61 | public function ensure_that_start_and_end_timezones_are_equal(): void 62 | { 63 | new DateTimePeriod( 64 | new DateTimeImmutable('2017-10-10T10:10:10+02:00'), 65 | new DateTimeImmutable('2017-10-10T10:10:10-05:00') 66 | ); 67 | } 68 | 69 | /** 70 | * @test 71 | * @expectedException \Pwm\DateTimePeriod\Exceptions\NegativeDateTimePeriod 72 | */ 73 | public function it_throws_if_start_date_is_before_end_date(): void 74 | { 75 | new DateTimePeriod(new DateTimeImmutable('+1 day'), new DateTimeImmutable('-1 day')); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function check_precedes_predicate(): void 82 | { 83 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 84 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T14:00:00+00:00'), new DateTimeImmutable('2017-01-01T16:00:00+00:00')); 85 | 86 | self::assertTrue($a->precedes($b)); 87 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['precedes']), $a, $b)); 88 | self::assertTrue($b->precededBy($a)); // converse relation 89 | } 90 | 91 | /** 92 | * @test 93 | */ 94 | public function check_meets_predicate(): void 95 | { 96 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 97 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T12:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 98 | 99 | self::assertTrue($a->meets($b)); 100 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['meets']), $a, $b)); 101 | self::assertTrue($b->metBy($a)); // converse relation 102 | } 103 | 104 | /** 105 | * @test 106 | */ 107 | public function check_overlaps_predicate(): void 108 | { 109 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T11:00:00+00:00'), new DateTimeImmutable('2017-01-01T13:00:00+00:00')); 110 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T12:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 111 | 112 | self::assertTrue($a->overlaps($b)); 113 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['overlaps']), $a, $b)); 114 | self::assertTrue($b->overlappedBy($a)); // converse relation 115 | } 116 | 117 | /** 118 | * @test 119 | */ 120 | public function check_finishedBy_predicate(): void 121 | { 122 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 123 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T12:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 124 | 125 | self::assertTrue($a->finishedBy($b)); 126 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['finishedBy']), $a, $b)); 127 | self::assertTrue($b->finishes($a)); // converse relation 128 | } 129 | 130 | /** 131 | * @test 132 | */ 133 | public function check_contains_predicate(): void 134 | { 135 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 136 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T11:00:00+00:00'), new DateTimeImmutable('2017-01-01T13:00:00+00:00')); 137 | 138 | self::assertTrue($a->contains($b)); 139 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['contains']), $a, $b)); 140 | self::assertTrue($b->during($a)); // converse relation 141 | } 142 | 143 | /** 144 | * @test 145 | */ 146 | public function check_starts_predicate(): void 147 | { 148 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 149 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 150 | 151 | self::assertTrue($a->starts($b)); 152 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['starts']), $a, $b)); 153 | self::assertTrue($b->startedBy($a)); // converse relation 154 | } 155 | 156 | /** 157 | * @test 158 | */ 159 | public function check_equals_predicate(): void 160 | { 161 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 162 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 163 | 164 | self::assertTrue($a->equals($b)); 165 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['equals']), $a, $b)); 166 | self::assertTrue($b->equals($a)); // converse relation (equals is the converse of itself) 167 | } 168 | 169 | /** 170 | * @test 171 | */ 172 | public function check_startedBy_predicate(): void 173 | { 174 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 175 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 176 | 177 | self::assertTrue($a->startedBy($b)); 178 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['startedBy']), $a, $b)); 179 | self::assertTrue($b->starts($a)); // converse relation 180 | } 181 | 182 | /** 183 | * @test 184 | */ 185 | public function check_during_predicate(): void 186 | { 187 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T11:00:00+00:00'), new DateTimeImmutable('2017-01-01T13:00:00+00:00')); 188 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 189 | 190 | self::assertTrue($a->during($b)); 191 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['during']), $a, $b)); 192 | self::assertTrue($b->contains($a)); // converse relation 193 | } 194 | 195 | /** 196 | * @test 197 | */ 198 | public function check_finishes_predicate(): void 199 | { 200 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T12:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 201 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 202 | 203 | self::assertTrue($a->finishes($b)); 204 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['finishes']), $a, $b)); 205 | self::assertTrue($b->finishedBy($a)); // converse relation 206 | } 207 | 208 | /** 209 | * @test 210 | */ 211 | public function check_overlappedBy_predicate(): void 212 | { 213 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T12:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 214 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T11:00:00+00:00'), new DateTimeImmutable('2017-01-01T13:00:00+00:00')); 215 | 216 | self::assertTrue($a->overlappedBy($b)); 217 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['overlappedBy']), $a, $b)); 218 | self::assertTrue($b->overlaps($a)); // converse relation 219 | } 220 | 221 | /** 222 | * @test 223 | */ 224 | public function check_metBy_predicate(): void 225 | { 226 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T12:00:00+00:00'), new DateTimeImmutable('2017-01-01T14:00:00+00:00')); 227 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 228 | 229 | self::assertTrue($a->metBy($b)); 230 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['metBy']), $a, $b)); 231 | self::assertTrue($b->meets($a)); // converse relation 232 | } 233 | 234 | /** 235 | * @test 236 | */ 237 | public function check_precededBy_predicate(): void 238 | { 239 | $a = new DateTimePeriod(new DateTimeImmutable('2017-01-01T14:00:00+00:00'), new DateTimeImmutable('2017-01-01T16:00:00+00:00')); 240 | $b = new DateTimePeriod(new DateTimeImmutable('2017-01-01T10:00:00+00:00'), new DateTimeImmutable('2017-01-01T12:00:00+00:00')); 241 | 242 | self::assertTrue($a->precededBy($b)); 243 | self::assertFalse(self::checkOtherPredicates(array_diff(self::PREDICATES, ['precededBy']), $a, $b)); 244 | self::assertTrue($b->precedes($a)); // converse relation 245 | } 246 | 247 | /** 248 | * @test 249 | */ 250 | public function relations_differ_on_timelines_with_different_granularities(): void 251 | { 252 | $aStart = '2017-01-01T12:12:09.829462+00:00'; 253 | $aEnd = '2017-01-01T14:23:34.534678+00:00'; 254 | $bStart = '2017-01-01T14:41:57.657388+00:00'; 255 | $bEnd = '2017-01-01T16:19:03.412832+00:00'; 256 | 257 | // The 2 periods created using the above instants meet on a timeline with an hourly granule 258 | $hourGranule = 'Y-m-d\TH'; 259 | $a = new DateTimePeriod( 260 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($aStart))->format($hourGranule)), 261 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($aEnd))->format($hourGranule)) 262 | ); 263 | $b = new DateTimePeriod( 264 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($bStart))->format($hourGranule)), 265 | DateTimeImmutable::createFromFormat($hourGranule, (new DateTimeImmutable($bEnd))->format($hourGranule)) 266 | ); 267 | self::assertTrue($a->meets($b)); 268 | 269 | // The 2 periods created using the above instants do not meet on a timeline with a minutely granule 270 | $minuteGranule = 'Y-m-d\TH:i'; 271 | $a = new DateTimePeriod( 272 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($aStart))->format($minuteGranule)), 273 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($aEnd))->format($minuteGranule)) 274 | ); 275 | 276 | $b = new DateTimePeriod( 277 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($bStart))->format($minuteGranule)), 278 | DateTimeImmutable::createFromFormat($minuteGranule, (new DateTimeImmutable($bEnd))->format($minuteGranule)) 279 | ); 280 | self::assertFalse($a->meets($b)); 281 | } 282 | 283 | /** 284 | * @test 285 | * @expectedException \Pwm\DateTimePeriod\Exceptions\UTCOffsetMismatch 286 | */ 287 | public function timezones_can_represent_different_utc_offsets_as_a_result_of_dst(): void 288 | { 289 | /* 290 | * This 1 year period have 2 dates whose timezones look the same but they represent different UTC offsets. 291 | * The first one generates a UTC+01 (BST) datetime while the 2nd one generates a UTC+00 (GMT) one. 292 | * This is because daylight saving time (DST) happens on different days in different years. 293 | */ 294 | new DateTimePeriod( 295 | new DateTimeImmutable('2018-03-26T08:00:00', new DateTimeZone('Europe/London')), 296 | new DateTimeImmutable('2019-03-26T08:00:00', new DateTimeZone('Europe/London')) 297 | ); 298 | } 299 | 300 | /** 301 | * @test 302 | */ 303 | public function it_can_map_timezones_to_utc_offsets(): void 304 | { 305 | self::assertSame('+01:00', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2018-03-26T08:00:00', new DateTimeZone('Europe/London')))); 306 | self::assertSame('+00:00', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2019-03-26T08:00:00', new DateTimeZone('Europe/London')))); 307 | 308 | self::assertSame('-04:00', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2018-03-11T08:00:00', new DateTimeZone('America/New_York')))); 309 | self::assertSame('-05:00', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2021-03-11T08:00:00', new DateTimeZone('America/New_York')))); 310 | 311 | self::assertSame('+03:00', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2018-03-25T08:00:00', new DateTimeZone('Europe/Kiev')))); 312 | self::assertSame('+02:00', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2019-03-25T08:00:00', new DateTimeZone('Europe/Kiev')))); 313 | 314 | self::assertSame('+09:30', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2018-04-01T08:00:00', new DateTimeZone('Australia/Adelaide')))); 315 | self::assertSame('+10:30', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2019-04-01T08:00:00', new DateTimeZone('Australia/Adelaide')))); 316 | 317 | self::assertSame('-02:30', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2018-03-11T08:00:00', new DateTimeZone('America/St_Johns')))); 318 | self::assertSame('-03:30', DateTimePeriod::getUtcOffset(new DateTimeImmutable('2021-03-11T08:00:00', new DateTimeZone('America/St_Johns')))); 319 | } 320 | 321 | /** 322 | * @test 323 | */ 324 | public function it_returns_the_correct_number_of_days_in_a_period(): void 325 | { 326 | $dt = new DateTimeImmutable('2017-01-01T12:00:00+00:00'); 327 | self::assertSame(0, (new DateTimePeriod($dt, $dt))->getNumberOfDays()); 328 | 329 | $dts = new DateTimeImmutable('2017-01-01T12:00:00+00:00'); 330 | $dte = new DateTimeImmutable('2017-01-02T11:59:59+00:00'); 331 | self::assertSame(0, (new DateTimePeriod($dts, $dte))->getNumberOfDays()); 332 | 333 | $dts = new DateTimeImmutable('2017-01-01T12:00:00+00:00'); 334 | $dte = new DateTimeImmutable('2017-01-02T12:00:00+00:00'); 335 | self::assertSame(1, (new DateTimePeriod($dts, $dte))->getNumberOfDays()); 336 | 337 | $dts = new DateTimeImmutable('2017-01-01T12:00:00+00:00'); 338 | $dte = new DateTimeImmutable('2018-01-01T12:00:00+00:00'); 339 | self::assertSame(365, (new DateTimePeriod($dts, $dte))->getNumberOfDays()); 340 | 341 | // leap year 342 | $dts = new DateTimeImmutable('2016-01-01T12:00:00+00:00'); 343 | $dte = new DateTimeImmutable('2017-01-01T12:00:00+00:00'); 344 | self::assertSame(366, (new DateTimePeriod($dts, $dte))->getNumberOfDays()); 345 | } 346 | 347 | private static function checkOtherPredicates(array $predicates, DateTimePeriod $a, DateTimePeriod $b): bool 348 | { 349 | return array_reduce($predicates, function (bool $result, string $predicate) use ($a, $b) { 350 | return $result || $a->{$predicate}($b); 351 | }, false); 352 | } 353 | } 354 | --------------------------------------------------------------------------------