diff --git a/app/Support/DateRange.php b/app/Support/DateRange.php new file mode 100644 index 00000000..4f810b82 --- /dev/null +++ b/app/Support/DateRange.php @@ -0,0 +1,73 @@ +lessThan($from)) { + throw new InvalidArgumentException('The end of a date range cannot precede its start.'); + } + } + + public static function preset(string $preset): self + { + $now = Carbon::now(); + + return match ($preset) { + 'today' => new self($now->copy()->startOfDay(), $now->copy()->endOfDay()), + 'week' => new self($now->copy()->startOfWeek(), $now->copy()->endOfWeek()), + 'month' => new self($now->copy()->startOfMonth(), $now->copy()->endOfMonth()), + 'year' => new self($now->copy()->startOfYear(), $now->copy()->endOfYear()), + 'all' => new self(Carbon::createFromTimestamp(0), $now->copy()->endOfDay()), + default => throw new InvalidArgumentException("Unknown date range preset [{$preset}]."), + }; + } + + /** + * @return array + */ + public static function presets(): array + { + return [ + 'today' => 'Today', + 'week' => 'This Week', + 'month' => 'This Month', + 'year' => 'This Year', + 'all' => 'All Time', + ]; + } + + /** + * Every day the range touches, as Y-m-d, so callers can zero-fill empty buckets. + * + * @return array + */ + public function days(): array + { + $days = []; + $cursor = $this->from->copy()->startOfDay(); + $last = $this->to->copy()->startOfDay(); + + if ($cursor->diffInDays($last) >= self::MAX_DAYS) { + throw new InvalidArgumentException( + 'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.' + ); + } + + while ($cursor->lessThanOrEqualTo($last)) { + $days[] = $cursor->toDateString(); + $cursor->addDay(); + } + + return $days; + } +} diff --git a/tests/Unit/Support/DateRangeTest.php b/tests/Unit/Support/DateRangeTest.php new file mode 100644 index 00000000..4a2aa93b --- /dev/null +++ b/tests/Unit/Support/DateRangeTest.php @@ -0,0 +1,186 @@ +assertTrue($from->equalTo($range->from)); + $this->assertTrue($to->equalTo($range->to)); + } + + public function test_it_rejects_a_to_earlier_than_from(): void + { + $this->expectException(InvalidArgumentException::class); + + new DateRange( + Carbon::parse('2026-07-31 00:00:00'), + Carbon::parse('2026-07-01 00:00:00'), + ); + } + + public function test_it_allows_a_range_covering_a_single_instant(): void + { + $instant = Carbon::parse('2026-07-01 12:00:00'); + + $range = new DateRange($instant, $instant); + + $this->assertTrue($range->from->equalTo($range->to)); + } + + public function test_it_builds_today_as_the_current_day(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + $range = DateRange::preset('today'); + + $this->assertSame('2026-07-15 00:00:00', $range->from->toDateTimeString()); + $this->assertSame('2026-07-15 23:59:59', $range->to->toDateTimeString()); + } + + public function test_it_builds_week_month_and_year_presets(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + $week = DateRange::preset('week'); + $this->assertTrue($week->from->equalTo(Carbon::parse('2026-07-15')->startOfWeek())); + $this->assertTrue($week->to->equalTo(Carbon::parse('2026-07-15')->endOfWeek())); + + $month = DateRange::preset('month'); + $this->assertSame('2026-07-01 00:00:00', $month->from->toDateTimeString()); + $this->assertSame('2026-07-31 23:59:59', $month->to->toDateTimeString()); + + $year = DateRange::preset('year'); + $this->assertSame('2026-01-01 00:00:00', $year->from->toDateTimeString()); + $this->assertSame('2026-12-31 23:59:59', $year->to->toDateTimeString()); + } + + public function test_it_builds_all_as_a_bounded_range_ending_now(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + $range = DateRange::preset('all'); + + $this->assertSame('2026-07-15 23:59:59', $range->to->toDateTimeString()); + $this->assertTrue($range->from->lessThan(Carbon::parse('2000-01-01'))); + } + + public function test_it_rejects_an_unknown_preset(): void + { + $this->expectException(InvalidArgumentException::class); + + DateRange::preset('fortnight'); + } + + public function test_it_lists_the_available_presets(): void + { + $presets = DateRange::presets(); + + $this->assertSame( + ['today', 'week', 'month', 'year', 'all'], + array_keys($presets), + ); + $this->assertSame('Today', $presets['today']); + $this->assertSame('All Time', $presets['all']); + } + + public function test_it_lists_each_day_in_the_range(): void + { + $range = new DateRange( + Carbon::parse('2026-07-01 09:30:00'), + Carbon::parse('2026-07-04 18:00:00'), + ); + + $this->assertSame( + ['2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04'], + $range->days(), + ); + } + + public function test_it_lists_a_single_day_for_a_same_day_range(): void + { + $range = new DateRange( + Carbon::parse('2026-07-01 00:00:00'), + Carbon::parse('2026-07-01 23:59:59'), + ); + + $this->assertSame(['2026-07-01'], $range->days()); + } + + public function test_it_lists_days_across_a_month_boundary(): void + { + $range = new DateRange( + Carbon::parse('2026-01-30 00:00:00'), + Carbon::parse('2026-02-02 23:59:59'), + ); + + $this->assertSame( + ['2026-01-30', '2026-01-31', '2026-02-01', '2026-02-02'], + $range->days(), + ); + } + + public function test_it_lists_days_across_a_year_boundary(): void + { + $range = new DateRange( + Carbon::parse('2025-12-30 00:00:00'), + Carbon::parse('2026-01-02 23:59:59'), + ); + + $this->assertSame( + ['2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02'], + $range->days(), + ); + } + + public function test_it_lists_every_day_of_a_leap_february(): void + { + $range = new DateRange( + Carbon::parse('2028-02-27 00:00:00'), + Carbon::parse('2028-03-01 23:59:59'), + ); + + $this->assertSame( + ['2028-02-27', '2028-02-28', '2028-02-29', '2028-03-01'], + $range->days(), + ); + } + + public function test_it_lists_days_up_to_the_maximum_span(): void + { + $from = Carbon::parse('2026-01-01 00:00:00'); + $range = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS - 1)->endOfDay()); + + $this->assertCount(DateRange::MAX_DAYS, $range->days()); + } + + public function test_it_refuses_to_list_days_for_a_range_wider_than_the_maximum(): void + { + $from = Carbon::parse('2026-01-01 00:00:00'); + $range = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS)->endOfDay()); + + $this->expectException(InvalidArgumentException::class); + + $range->days(); + } + + public function test_it_refuses_to_list_days_for_the_all_preset(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + $this->expectException(InvalidArgumentException::class); + + DateRange::preset('all')->days(); + } +}