diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 1f12316..3245243 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -45,7 +45,7 @@ jobs: - run: composer install --no-interaction --no-progress --no-suggest - - run: vendor/bin/php-cs-fixer fix + - run: vendor/bin/php-cs-fixer fix --using-cache=no - uses: stefanzweifel/git-auto-commit-action@v4 with: diff --git a/.gitignore b/.gitignore index 2a0492a..468ff9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ /.build +/.idea /vendor /composer.lock -/.idea -/.php-cs-fixer.cache -/.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bef553..dbbc30c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ See [GitHub releases](https://github.com/mll-lab/php-utils/releases). ## Unreleased +## v1.11.0 + +### Added + +- Integrate `mll-lab/holidays` + ## v1.10.0 ### Added diff --git a/Makefile b/Makefile index fdaeaf6..31897a8 100644 --- a/Makefile +++ b/Makefile @@ -7,28 +7,35 @@ help: ## Displays this list of targets with descriptions .PHONY: coverage coverage: vendor ## Collects coverage from running unit tests with phpunit - mkdir -p .build/phpunit + mkdir --parents .build/phpunit vendor/bin/phpunit --dump-xdebug-filter=.build/phpunit/xdebug-filter.php vendor/bin/phpunit --coverage-text --prepend=.build/phpunit/xdebug-filter.php .PHONY: fix -fix: vendor +fix: rector php-cs-fixer + +.PHONY: rector +rector: vendor vendor/bin/rector process - vendor/bin/php-cs-fixer fix + +.PHONY: php-cs-fixer +php-cs-fixer: + mkdir --parents .build/php-cs-fixer + vendor/bin/php-cs-fixer fix --cache-file=.build/php-cs-fixer/cache .PHONY: infection infection: vendor ## Runs mutation tests with infection - mkdir -p .build/infection + mkdir --parents .build/infection vendor/bin/infection --ignore-msi-with-no-mutations --min-covered-msi=60 --min-msi=60 .PHONY: stan stan: vendor ## Runs a static analysis with phpstan - mkdir -p .build/phpstan + mkdir --parents .build/phpstan vendor/bin/phpstan analyse --configuration=phpstan.neon .PHONY: test test: vendor ## Runs auto-review, unit, and integration tests with phpunit - mkdir -p .build/phpunit + mkdir --parents .build/phpunit vendor/bin/phpunit --cache-result-file=.build/phpunit/result.cache vendor: composer.json diff --git a/composer.json b/composer.json index 1ec4f98..b6055e5 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,11 @@ }, "require": { "php": "^7.4 || ^8", + "ext-calendar": "*", "illuminate/support": "^8.73 || ^9 || ^10", "mll-lab/microplate": "^6", "mll-lab/str_putcsv": "^1", + "nesbot/carbon": "^2.62.1", "thecodingmachine/safe": "^1 || ^2" }, "require-dev": { @@ -26,7 +28,6 @@ "infection/infection": "^0.26 || ^0.27", "jangregor/phpstan-prophecy": "^1", "mll-lab/php-cs-fixer-config": "^5", - "nesbot/carbon": "^2.62.1", "phpstan/extension-installer": "^1", "phpstan/phpstan": "^1", "phpstan/phpstan-deprecation-rules": "^1", @@ -34,7 +35,6 @@ "phpstan/phpstan-strict-rules": "^1", "phpunit/phpunit": "^9 || ^10", "rector/rector": "^0.17", - "symfony/var-dumper": "^5 || ^6", "thecodingmachine/phpstan-safe-rule": "^1.2" }, "autoload": { @@ -45,10 +45,7 @@ "autoload-dev": { "psr-4": { "MLL\\Utils\\Tests\\": "tests/" - }, - "files": [ - "vendor/symfony/var-dumper/Resources/functions/dump.php" - ] + } }, "config": { "allow-plugins": { diff --git a/src/BavarianHolidays.php b/src/BavarianHolidays.php new file mode 100644 index 0000000..e7dc4e0 --- /dev/null +++ b/src/BavarianHolidays.php @@ -0,0 +1,145 @@ + 'Neujahrstag', + '06.01' => 'Heilige Drei Könige', + '01.05' => 'Tag der Arbeit', + '15.08' => 'Maria Himmelfahrt', + '03.10' => 'Tag der Deutschen Einheit', + '01.11' => 'Allerheiligen', + '24.12' => 'Heilig Abend', + '25.12' => 'Erster Weihnachtstag', + '26.12' => 'Zweiter Weihnachtstag', + '31.12' => 'Sylvester', + ]; + + public const SAMSTAG = 'Samstag'; + public const SONNTAG = 'Sonntag'; + public const KARFREITAG = 'Karfreitag'; + public const OSTERSONNTAG = 'Ostersonntag'; + public const OSTERMONTAG = 'Ostermontag'; + public const CHRISTI_HIMMELFAHRT = 'Christi Himmelfahrt'; + public const PFINGSTSONNTAG = 'Pfingstsonntag'; + public const PFINGSTMONTAG = 'Pfingstmontag'; + public const FRONLEICHNAM = 'Fronleichnam'; + public const REFORMATIONSTAG_500_JAHRE_REFORMATION = 'Reformationstag (500 Jahre Reformation)'; + + /** + * Optionally allows users to define extra holidays for a given year. + * + * The returned array is expected to be a map from the day of the year + * (format with @see self::dayOfTheYear()) to holiday names. + * + * @example ['23.02' => 'Day of the Tentacle'] + * + * @var (callable(int): array)|null + */ + public static $loadUserDefinedHolidays; + + /** Checks if given date is a business day. */ + public static function isBusinessDay(Carbon $date): bool + { + return ! self::isHoliday($date) + && ! $date->isWeekend(); + } + + /** Checks if given date is a holiday. */ + public static function isHoliday(Carbon $date): bool + { + return is_string(self::nameHoliday($date)); + } + + /** + * Returns the name of the holiday if the date happens to land on one. + * Saturday and Sunday are not evaluated as holiday. + */ + public static function nameHoliday(Carbon $date): ?string + { + $holidayMap = self::buildHolidayMap($date); + + return $holidayMap[self::dayOfTheYear($date)] ?? null; + } + + /** Returns a new carbon instance with the given number of business days added. */ + public static function addBusinessDays(Carbon $date, int $days): Carbon + { + return DateModification::addDays( + $date, + $days, + fn (Carbon $date): bool => self::isBusinessDay($date) + ); + } + + /** Returns a new carbon instance with the given number of business days subtracted. */ + public static function subBusinessDays(Carbon $date, int $days): Carbon + { + return DateModification::subDays( + $date, + $days, + fn (Carbon $date): bool => self::isBusinessDay($date) + ); + } + + /** + * Returns a map from day/month to named holidays. + * + * @return array + */ + protected static function buildHolidayMap(Carbon $date): array + { + $holidays = self::HOLIDAYS_STATIC; + + $year = $date->year; + + // dynamic holidays + // easter_days avoids issues with timezones and is not limited to UNIX timestamps, see https://github.com/briannesbitt/Carbon/pull/1052#issuecomment-381178494 + $easter = Carbon::createMidnightDate($year, 3, 21) + ->addDays(easter_days($year)); + $holidays[self::dateFromEaster($easter, -2)] = self::KARFREITAG; + $holidays[self::dateFromEaster($easter, 0)] = self::OSTERSONNTAG; + $holidays[self::dateFromEaster($easter, 1)] = self::OSTERMONTAG; + $holidays[self::dateFromEaster($easter, 39)] = self::CHRISTI_HIMMELFAHRT; + $holidays[self::dateFromEaster($easter, 49)] = self::PFINGSTSONNTAG; + $holidays[self::dateFromEaster($easter, 50)] = self::PFINGSTMONTAG; + $holidays[self::dateFromEaster($easter, 60)] = self::FRONLEICHNAM; + + // exceptional holidays + if ($year === 2017) { + $holidays['31.10'] = self::REFORMATIONSTAG_500_JAHRE_REFORMATION; + } + + // user-defined holidays + if (isset(self::$loadUserDefinedHolidays)) { + $holidays = array_merge( + $holidays, + (self::$loadUserDefinedHolidays)($year) + ); + } + + return $holidays; + } + + protected static function dateFromEaster(Carbon $easter, int $daysAway): string + { + $date = $easter->clone()->addDays($daysAway); + + return self::dayOfTheYear($date); + } + + public static function dayOfTheYear(Carbon $date): string + { + return $date->format('d.m'); + } +} diff --git a/src/DateModification.php b/src/DateModification.php new file mode 100644 index 0000000..49af2e6 --- /dev/null +++ b/src/DateModification.php @@ -0,0 +1,40 @@ +clone(); + + while ($days > 0) { + $copy->addDay(); + if ($shouldCount($copy)) { + --$days; + } + } + + return $copy; + } + + /** @param callable(Carbon): bool $shouldCount should the given date be subtracted? */ + public static function subDays(Carbon $date, int $days, callable $shouldCount): Carbon + { + // Make sure we do not mutate the original date + $copy = $date->clone(); + + while ($days > 0) { + $copy->subDay(); + if ($shouldCount($copy)) { + --$days; + } + } + + return $copy; + } +} diff --git a/tests/BavarianHolidaysTest.php b/tests/BavarianHolidaysTest.php new file mode 100644 index 0000000..77d55a4 --- /dev/null +++ b/tests/BavarianHolidaysTest.php @@ -0,0 +1,145 @@ +addDays(2); + + self::assertTrue( + BavarianHolidays::addBusinessDays($saturday, 1) + ->isSameDay($mondayAfter), + 'Skips over sunday' + ); + self::assertTrue( + $saturday->isSameDay(self::saturday()), + 'Should not mutate the original date' + ); + } + + public function testSubBusinessDays(): void + { + $sunday = self::sunday(); + $thursdayBeforeAllSaints = self::sunday()->subDays(3); + self::assertTrue( + BavarianHolidays::subBusinessDays($sunday, 1) + ->isSameDay($thursdayBeforeAllSaints), + 'Skips over saturday and all saints holiday (01.01.2019)' + ); + self::assertTrue( + $sunday->isSameDay(self::sunday()), + 'Should not mutate the original date' + ); + } + + protected static function businessDayWednesday(): Carbon + { + return Carbon::createStrict(2019, 10, 30); + } + + protected static function karfreitag2019(): Carbon + { + return Carbon::createStrict(2019, 4, 19); + } + + /** Before UNIX timestamps. */ + protected static function easterSunday1969(): Carbon + { + return Carbon::createStrict(1969, 4, 6); + } + + protected static function easterSunday2019(): Carbon + { + return Carbon::createStrict(2019, 4, 21); + } + + protected static function saturday(): Carbon + { + return Carbon::createStrict(2019, 11, 2); + } + + protected static function sunday(): Carbon + { + return Carbon::createStrict(2019, 11, 3); + } + + /** @return iterable */ + public static function businessDays(): iterable + { + yield [self::businessDayWednesday()]; + } + + /** @return iterable */ + public static function holidays(): iterable + { + yield [self::karfreitag2019()]; + yield [self::easterSunday1969()]; + yield [self::easterSunday2019()]; + } + + /** @return iterable */ + public static function weekend(): iterable + { + yield [self::saturday()]; + yield [self::sunday()]; + } + + public function testLoadUserDefinedHolidays(): void + { + $yearOfTheTentacle = 2019; + $dayOfTheTentacle = Carbon::createStrict($yearOfTheTentacle, 8, 22); + + self::assertNull(BavarianHolidays::nameHoliday($dayOfTheTentacle)); + self::assertFalse(BavarianHolidays::isHoliday($dayOfTheTentacle)); + self::assertTrue(BavarianHolidays::isBusinessDay($dayOfTheTentacle)); + + $name = 'Day of the Tentacle'; + BavarianHolidays::$loadUserDefinedHolidays = static function (int $year) use ($yearOfTheTentacle, $dayOfTheTentacle, $name): array { + switch ($year) { + case $yearOfTheTentacle: + return [BavarianHolidays::dayOfTheYear($dayOfTheTentacle) => $name]; + default: + self::fail("Expected the year of the passed in date to be passed, got {$year}."); + } + }; + + self::assertSame($name, BavarianHolidays::nameHoliday($dayOfTheTentacle)); + self::assertTrue(BavarianHolidays::isHoliday($dayOfTheTentacle)); + self::assertFalse(BavarianHolidays::isBusinessDay($dayOfTheTentacle)); + } +} diff --git a/tests/DateModificationTest.php b/tests/DateModificationTest.php new file mode 100644 index 0000000..95700b0 --- /dev/null +++ b/tests/DateModificationTest.php @@ -0,0 +1,26 @@ +addDays(2); + self::assertTrue( + DateModification::addDays($sunday, 1, fn (Carbon $date): bool => $date->dayOfWeek !== CarbonInterface::MONDAY) + ->isSameDay($tuesdayAfter) + ); + } + + protected static function sunday(): Carbon + { + return Carbon::createStrict(2019, 11, 3); + } +}