From f6067eec3862bbf06c9f4247a5161d97cdd70ebd Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 12 Aug 2026 23:14:19 +0200 Subject: [PATCH] 83 - Add a SeriesStat contract for time-series dashboard panels --- .gitignore | 1 + app/Dashboard/Stats/DailySeriesStat.php | 22 ++++ app/Dashboard/Stats/Series.php | 14 ++ app/Dashboard/Stats/SeriesResult.php | 45 +++++++ app/Dashboard/Stats/SeriesStat.php | 10 ++ app/Support/DateRange.php | 7 +- .../Dashboard/Stats/DailySeriesStatTest.php | 121 ++++++++++++++++++ .../Unit/Dashboard/Stats/SeriesResultTest.php | 63 +++++++++ .../Unit/Dashboard/Stats/StatContractTest.php | 6 + tests/Unit/Support/DateRangeTest.php | 43 +++++++ 10 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 app/Dashboard/Stats/DailySeriesStat.php create mode 100644 app/Dashboard/Stats/Series.php create mode 100644 app/Dashboard/Stats/SeriesResult.php create mode 100644 app/Dashboard/Stats/SeriesStat.php create mode 100644 tests/Unit/Dashboard/Stats/DailySeriesStatTest.php create mode 100644 tests/Unit/Dashboard/Stats/SeriesResultTest.php diff --git a/.gitignore b/.gitignore index 04181efc..9e3156f0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ yarn-error.log /.php-cs-fixer.dist.php /.php-cs-fixer.cache /.codewhale +.aider* diff --git a/app/Dashboard/Stats/DailySeriesStat.php b/app/Dashboard/Stats/DailySeriesStat.php new file mode 100644 index 00000000..c089aad6 --- /dev/null +++ b/app/Dashboard/Stats/DailySeriesStat.php @@ -0,0 +1,22 @@ +isBucketableByDay()) { + return SeriesResult::tooWide(); + } + + return $this->series($range, $range->days()); + } + + /** + * @param array $days + */ + abstract protected function series(DateRange $range, array $days): SeriesResult; +} diff --git a/app/Dashboard/Stats/Series.php b/app/Dashboard/Stats/Series.php new file mode 100644 index 00000000..554ab801 --- /dev/null +++ b/app/Dashboard/Stats/Series.php @@ -0,0 +1,14 @@ + $values + */ + public function __construct( + public readonly string $name, + public readonly array $values, + ) {} +} diff --git a/app/Dashboard/Stats/SeriesResult.php b/app/Dashboard/Stats/SeriesResult.php new file mode 100644 index 00000000..a84fe4fd --- /dev/null +++ b/app/Dashboard/Stats/SeriesResult.php @@ -0,0 +1,45 @@ + $labels + * @param array $series + */ + public function __construct( + public readonly array $labels, + public readonly array $series, + ) { + foreach ($series as $one) { + if (count($one->values) !== count($labels)) { + throw new InvalidArgumentException( + "Series [{$one->name}] has ".count($one->values).' values for '.count($labels).' labels.' + ); + } + } + } + + public static function tooWide(): self + { + $result = new self([], []); + $result->tooWide = true; + + return $result; + } + + public function isTooWide(): bool + { + return $this->tooWide; + } + + public function isEmpty(): bool + { + return ! $this->tooWide && $this->labels === []; + } +} diff --git a/app/Dashboard/Stats/SeriesStat.php b/app/Dashboard/Stats/SeriesStat.php new file mode 100644 index 00000000..d9e73e96 --- /dev/null +++ b/app/Dashboard/Stats/SeriesStat.php @@ -0,0 +1,10 @@ +from->copy()->startOfDay()->diffInDays($this->to->copy()->startOfDay()) < self::MAX_DAYS; + } + /** * Every day the range touches, as Y-m-d, so callers can zero-fill empty buckets. * @@ -57,7 +62,7 @@ public function days(): array $cursor = $this->from->copy()->startOfDay(); $last = $this->to->copy()->startOfDay(); - if ($cursor->diffInDays($last) >= self::MAX_DAYS) { + if (! $this->isBucketableByDay()) { throw new InvalidArgumentException( 'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.' ); diff --git a/tests/Unit/Dashboard/Stats/DailySeriesStatTest.php b/tests/Unit/Dashboard/Stats/DailySeriesStatTest.php new file mode 100644 index 00000000..b8f7f9a4 --- /dev/null +++ b/tests/Unit/Dashboard/Stats/DailySeriesStatTest.php @@ -0,0 +1,121 @@ +bucketableRange(); + $expected = new SeriesResult($range->days(), [new Series('Approved', [1, 2, 3, 4, 5, 6, 7])]); + + $stat = new class($expected) extends DailySeriesStat + { + public bool $seriesWasCalled = false; + + public function __construct(private readonly SeriesResult $result) {} + + public function key(): string + { + return 'fake'; + } + + public function label(): string + { + return 'Fake'; + } + + protected function series(DateRange $range, array $days): SeriesResult + { + $this->seriesWasCalled = true; + + return $this->result; + } + }; + + $result = $stat->for($range); + + $this->assertTrue($stat->seriesWasCalled); + $this->assertSame($expected, $result); + } + + public function test_for_returns_a_too_wide_result_without_calling_series_on_a_non_bucketable_range(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + $range = DateRange::preset('all'); + + $stat = new class extends DailySeriesStat + { + public bool $seriesWasCalled = false; + + public function key(): string + { + return 'fake'; + } + + public function label(): string + { + return 'Fake'; + } + + protected function series(DateRange $range, array $days): SeriesResult + { + $this->seriesWasCalled = true; + + return new SeriesResult([], []); + } + }; + + $result = $stat->for($range); + + $this->assertFalse($stat->seriesWasCalled); + $this->assertTrue($result->isTooWide()); + } + + public function test_for_passes_the_ranges_days_through_to_series(): void + { + $range = $this->bucketableRange(); + + $stat = new class extends DailySeriesStat + { + /** @var array|null */ + public ?array $receivedDays = null; + + public function key(): string + { + return 'fake'; + } + + public function label(): string + { + return 'Fake'; + } + + protected function series(DateRange $range, array $days): SeriesResult + { + $this->receivedDays = $days; + + return new SeriesResult($days, []); + } + }; + + $stat->for($range); + + $this->assertSame($range->days(), $stat->receivedDays); + } +} diff --git a/tests/Unit/Dashboard/Stats/SeriesResultTest.php b/tests/Unit/Dashboard/Stats/SeriesResultTest.php new file mode 100644 index 00000000..8cc51423 --- /dev/null +++ b/tests/Unit/Dashboard/Stats/SeriesResultTest.php @@ -0,0 +1,63 @@ +expectException(InvalidArgumentException::class); + + new SeriesResult( + ['2026-07-01', '2026-07-02', '2026-07-03'], + [new Series('Approved', [1, 2])], + ); + } + + public function test_a_series_result_accepts_multiple_series_that_all_align(): void + { + $result = new SeriesResult( + ['2026-07-01', '2026-07-02'], + [ + new Series('Approved', [1, 2]), + new Series('Rejected', [0, 1]), + ], + ); + + $this->assertSame(['2026-07-01', '2026-07-02'], $result->labels); + $this->assertCount(2, $result->series); + } + + public function test_a_too_wide_result_reports_itself_as_too_wide(): void + { + $result = SeriesResult::tooWide(); + + $this->assertTrue($result->isTooWide()); + } + + public function test_a_too_wide_result_is_not_reported_as_merely_empty(): void + { + $result = SeriesResult::tooWide(); + + $this->assertFalse($result->isEmpty()); + } + + public function test_an_empty_but_valid_result_is_not_reported_as_too_wide(): void + { + $result = new SeriesResult([], []); + + $this->assertFalse($result->isTooWide()); + } + + public function test_an_empty_but_valid_result_reports_itself_as_empty(): void + { + $result = new SeriesResult([], []); + + $this->assertTrue($result->isEmpty()); + } +} diff --git a/tests/Unit/Dashboard/Stats/StatContractTest.php b/tests/Unit/Dashboard/Stats/StatContractTest.php index 9f662711..58864773 100644 --- a/tests/Unit/Dashboard/Stats/StatContractTest.php +++ b/tests/Unit/Dashboard/Stats/StatContractTest.php @@ -4,6 +4,7 @@ use App\Dashboard\Stats\ArticlesPerFeed; use App\Dashboard\Stats\BreakdownStat; +use App\Dashboard\Stats\SeriesStat; use App\Dashboard\Stats\Stat; use Tests\TestCase; @@ -30,4 +31,9 @@ public function test_articles_per_feed_is_a_breakdown_stat(): void { $this->assertInstanceOf(BreakdownStat::class, new ArticlesPerFeed); } + + public function test_a_series_stat_is_a_stat(): void + { + $this->assertTrue(is_subclass_of(SeriesStat::class, Stat::class)); + } } diff --git a/tests/Unit/Support/DateRangeTest.php b/tests/Unit/Support/DateRangeTest.php index 4a2aa93b..ae2bd6d9 100644 --- a/tests/Unit/Support/DateRangeTest.php +++ b/tests/Unit/Support/DateRangeTest.php @@ -183,4 +183,47 @@ public function test_it_refuses_to_list_days_for_the_all_preset(): void DateRange::preset('all')->days(); } + + public function test_it_is_bucketable_by_day_for_a_normal_range(): void + { + $this->assertTrue(DateRange::preset('week')->isBucketableByDay()); + $this->assertTrue(DateRange::preset('month')->isBucketableByDay()); + } + + public function test_it_is_bucketable_by_day_at_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->assertTrue($range->isBucketableByDay()); + } + + public function test_it_is_not_bucketable_by_day_one_day_beyond_the_maximum_span(): void + { + $from = Carbon::parse('2026-01-01 00:00:00'); + $range = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS)->endOfDay()); + + $this->assertFalse($range->isBucketableByDay()); + } + + public function test_it_is_not_bucketable_by_day_for_the_all_preset(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + $this->assertFalse(DateRange::preset('all')->isBucketableByDay()); + } + + public function test_is_bucketable_by_day_agrees_with_days_at_every_boundary(): void + { + $from = Carbon::parse('2026-01-01 00:00:00'); + + $withinBounds = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS - 1)->endOfDay()); + $withinBounds->days(); + $this->assertTrue($withinBounds->isBucketableByDay()); + + $beyondBounds = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS)->endOfDay()); + $this->assertFalse($beyondBounds->isBucketableByDay()); + $this->expectException(InvalidArgumentException::class); + $beyondBounds->days(); + } }