Release v1.4.0 #146
2 changed files with 259 additions and 0 deletions
73
app/Support/DateRange.php
Normal file
73
app/Support/DateRange.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class DateRange
|
||||||
|
{
|
||||||
|
public const MAX_DAYS = 731;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly Carbon $from,
|
||||||
|
public readonly Carbon $to,
|
||||||
|
) {
|
||||||
|
if ($to->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<string, string>
|
||||||
|
*/
|
||||||
|
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<int, string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
tests/Unit/Support/DateRangeTest.php
Normal file
186
tests/Unit/Support/DateRangeTest.php
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Support;
|
||||||
|
|
||||||
|
use App\Support\DateRange;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DateRangeTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_exposes_from_and_to(): void
|
||||||
|
{
|
||||||
|
$from = Carbon::parse('2026-07-01 00:00:00');
|
||||||
|
$to = Carbon::parse('2026-07-31 23:59:59');
|
||||||
|
|
||||||
|
$range = new DateRange($from, $to);
|
||||||
|
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue