diff --git a/app/Support/Calendar/Calculator.php b/app/Support/Calendar/Calculator.php new file mode 100644 index 0000000000..a26fb5ea18 --- /dev/null +++ b/app/Support/Calendar/Calculator.php @@ -0,0 +1,81 @@ +. + */ + +namespace FireflyIII\Support\Calendar; + +use Carbon\Carbon; +use FireflyIII\Support\Calendar\Exceptions\IntervalException; + +class Calculator +{ + const DEFAULT_INTERVAL = 1; + private static array $intervals = []; + private static ?\SplObjectStorage $intervalMap = null; + + private static function loadIntervalMap(): \SplObjectStorage + { + if (self::$intervalMap != null) { + return self::$intervalMap; + } + self::$intervalMap = new \SplObjectStorage(); + foreach (Periodicity::cases() as $interval) { + $periodicityClass = __NAMESPACE__ . "\\Periodicity\\{$interval->name}"; + self::$intervals[] = $interval->name; + self::$intervalMap->attach($interval, new $periodicityClass()); + } + return self::$intervalMap; + } + + private static function containsInterval(Periodicity $periodicity): bool + { + return self::loadIntervalMap()->contains($periodicity); + } + + public function isAvailablePeriodicity(Periodicity $periodicity): bool + { + return self::containsInterval($periodicity); + } + + private function skipInterval(int $skip): int + { + return self::DEFAULT_INTERVAL + $skip; + } + + /** + * @param Carbon $epoch + * @param Periodicity $periodicity + * @param int $skipInterval + * @return Carbon + * @throws IntervalException + */ + public function nextDateByInterval(Carbon $epoch, Periodicity $periodicity, int $skipInterval = 0): Carbon + { + if (!self::isAvailablePeriodicity($periodicity)) { + throw IntervalException::unavailable($periodicity, self::$intervals); + } + + /** @var Periodicity\Interval $periodicity */ + $periodicity = self::$intervalMap->offsetGet($periodicity); + $interval = $this->skipInterval($skipInterval); + return $periodicity->nextDate($epoch->clone(), $interval); + } + +} diff --git a/app/Support/Calendar/Exceptions/IntervalException.php b/app/Support/Calendar/Exceptions/IntervalException.php new file mode 100644 index 0000000000..40daccd8f6 --- /dev/null +++ b/app/Support/Calendar/Exceptions/IntervalException.php @@ -0,0 +1,28 @@ +name, + join(', ', $instervals) + ); + + $exception = new IntervalException($message, $code, $previous); + $exception->periodicity = $periodicity; + $exception->availableIntervals = $instervals; + return $exception; + } +} diff --git a/tests/Support/Calendar/CalculatorProvider.php b/tests/Support/Calendar/CalculatorProvider.php new file mode 100644 index 0000000000..dcbad2c8d3 --- /dev/null +++ b/tests/Support/Calendar/CalculatorProvider.php @@ -0,0 +1,120 @@ +. + */ + +namespace Tests\Support\Calendar; + +use Carbon\Carbon; +use FireflyIII\Support\Calendar\Periodicity; +use Tests\Support\Calendar\Periodicity\IntervalProvider; + +readonly class CalculatorProvider +{ + public IntervalProvider $intervalProvider; + public Periodicity $periodicity; + public string $label; + public int $skip; + + private function __construct(IntervalProvider $intervalProvider, Periodicity $periodicity, int $skip = 0) + { + $this->skip = $skip; + $this->intervalProvider = $intervalProvider; + $this->periodicity = $periodicity; + $this->label = "{$periodicity->name} {$intervalProvider->label}"; + } + + public static function from(Periodicity $periodicity, IntervalProvider $interval, int $skip = 0): CalculatorProvider + { + return new self($interval, $periodicity, $skip); + } + + public function epoch(): Carbon + { + return $this->intervalProvider->epoch; + } + + public function expected(): Carbon + { + return $this->intervalProvider->expected; + } + + public static function providePeriodicityWithSkippedIntervals(): \Generator + { + $intervals = [ + CalculatorProvider::from(Periodicity::Daily, new IntervalProvider(Carbon::now(), Carbon::now()->addDays(2)), 1), + CalculatorProvider::from(Periodicity::Daily, new IntervalProvider(Carbon::now(), Carbon::now()->addDays(3)), 2), + CalculatorProvider::from(Periodicity::Daily, new IntervalProvider(Carbon::parse('2023-01-31'), Carbon::parse('2023-02-11')), 10), + + CalculatorProvider::from(Periodicity::Weekly, new IntervalProvider(Carbon::now(), Carbon::now()->addWeeks(3)), 2), + CalculatorProvider::from(Periodicity::Weekly, new IntervalProvider(Carbon::parse('2023-01-31'), Carbon::parse('2023-02-14')), 1), + + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::now(), Carbon::now()->addWeeks(4)), 1), + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::parse('2023-01-29'), Carbon::parse('2023-02-26')), 1), + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::parse('2023-01-30'), Carbon::parse('2023-02-27')), 1), + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::parse('2023-01-31'), Carbon::parse('2023-02-28')), 1), + + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(2)), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2019-12-30'), Carbon::parse('2020-02-29')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2019-12-31'), Carbon::parse('2020-02-29')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-01-29'), Carbon::parse('2020-03-29')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-01-31'), Carbon::parse('2020-09-30')), 7), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-12-29'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-12-30'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-12-31'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-03-31'), Carbon::parse('2023-11-30')), 7), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-05-31'), Carbon::parse('2023-08-31')), 2), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-07-31'), Carbon::parse('2023-09-30')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-10-30'), Carbon::parse('2024-02-29')), 3), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-10-31'), Carbon::parse('2024-02-29')), 3), + + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(9)), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2019-05-29'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2019-05-30'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2019-05-31'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-02-29'), Carbon::parse('2021-02-28')), 3), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-08-29'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-08-30'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-08-31'), Carbon::parse('2021-02-28')), 1), + + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(12)), 1), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(18)), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(24)), 3), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2018-08-29'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2018-08-30'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2018-08-31'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2019-01-31'), Carbon::parse('2021-01-31')), 3), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2019-02-28'), Carbon::parse('2021-08-28')), 4), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-01-31'), Carbon::parse('2021-01-31')), 1), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-02-29'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-08-29'), Carbon::parse('2022-02-28')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-08-30'), Carbon::parse('2022-02-28')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-08-31'), Carbon::parse('2022-02-28')), 2), + + CalculatorProvider::from(Periodicity::Yearly, new IntervalProvider(Carbon::now(), Carbon::now()->addYearsNoOverflow(3)), 2), + CalculatorProvider::from(Periodicity::Yearly, new IntervalProvider(Carbon::parse('2019-01-29'), Carbon::parse('2025-01-29')), 5), + CalculatorProvider::from(Periodicity::Yearly, new IntervalProvider(Carbon::parse('2020-02-29'), Carbon::parse('2031-02-28')), 10), + ]; + + /** @var IntervalProvider $interval */ + foreach ($intervals as $index => $interval) { + yield "#{$index} {$interval->label}" => [$interval]; + } + } +} diff --git a/tests/Support/Calendar/CalculatorTest.php b/tests/Support/Calendar/CalculatorTest.php new file mode 100644 index 0000000000..c4fb07c6e8 --- /dev/null +++ b/tests/Support/Calendar/CalculatorTest.php @@ -0,0 +1,97 @@ +. + */ + +namespace Tests\Support\Calendar; + +use Carbon\Carbon; +use FireflyIII\Api\V1\Controllers\Insight\Income\PeriodController; +use FireflyIII\Support\Calendar\Calculator; +use FireflyIII\Support\Calendar\Exceptions\IntervalException; +use FireflyIII\Support\Calendar\Periodicity; +use FireflyIII\Support\Navigation; +use Tests\Support\Calendar\Periodicity\DailyTest; +use Tests\Support\Calendar\Periodicity\FortnightlyTest; +use Tests\Support\Calendar\Periodicity\HalfYearlyTest; +use Tests\Support\Calendar\Periodicity\IntervalProvider; +use Tests\Support\Calendar\Periodicity\MonthlyTest; +use Tests\Support\Calendar\Periodicity\QuarterlyTest; +use Tests\Support\Calendar\Periodicity\WeeklyTest; +use Tests\Support\Calendar\Periodicity\YearlyTest; +use Tests\TestCase; + +class CalculatorTest extends TestCase +{ + private static function convert(Periodicity $periodicity, array $intervals): array + { + $periodicityIntervals = []; + /** @var IntervalProvider $interval */ + foreach ($intervals as $index => $interval) { + $calculator = CalculatorProvider::from($periodicity, $interval); + + $periodicityIntervals["#{$index} {$calculator->label}"] = [$calculator]; + } + return $periodicityIntervals; + } + + public static function provideAllPeriodicity(): \Generator + { + $intervals = []; + $intervals = array_merge($intervals, self::convert(Periodicity::Daily, DailyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Weekly, WeeklyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Fortnightly, FortnightlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Monthly, MonthlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Quarterly, QuarterlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::HalfYearly, HalfYearlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Yearly, YearlyTest::provideIntervals())); + + /** @var IntervalProvider $interval */ + foreach ($intervals as $label => $interval) { + yield $label => $interval; + } + } + + /** + * @dataProvider provideAllPeriodicity + * @throws IntervalException + */ + public function testGivenADailyPeriodicityWhenCallTheNextDateByIntervalMethodThenReturnsTheExpectedDateSuccessful(CalculatorProvider $provider) + { + $calculator = new Calculator(); + $period = $calculator->nextDateByInterval($provider->epoch(), $provider->periodicity); + $this->assertEquals($provider->expected()->toDateString(), $period->toDateString()); + } + + public static function provideSkippedIntervals(): \Generator + { + return CalculatorProvider::providePeriodicityWithSkippedIntervals(); + } + + /** + * @dataProvider provideSkippedIntervals + * @throws IntervalException + */ + public function testGivenAnEpochWithSkipIntervalNumberWhenCallTheNextDateBySkippedIntervalMethodThenReturnsTheExpectedDateSuccessful(CalculatorProvider $provider) + { + $calculator = new Calculator(); + $period = $calculator->nextDateByInterval($provider->epoch(), $provider->periodicity, $provider->skip); + $this->assertEquals($provider->expected()->toDateString(), $period->toDateString()); + } +}