83 - Add a SeriesStat contract for time-series dashboard panels
This commit is contained in:
parent
55f76d0d09
commit
f6067eec38
10 changed files with 331 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,3 +26,4 @@ yarn-error.log
|
||||||
/.php-cs-fixer.dist.php
|
/.php-cs-fixer.dist.php
|
||||||
/.php-cs-fixer.cache
|
/.php-cs-fixer.cache
|
||||||
/.codewhale
|
/.codewhale
|
||||||
|
.aider*
|
||||||
|
|
|
||||||
22
app/Dashboard/Stats/DailySeriesStat.php
Normal file
22
app/Dashboard/Stats/DailySeriesStat.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Support\DateRange;
|
||||||
|
|
||||||
|
abstract class DailySeriesStat implements SeriesStat
|
||||||
|
{
|
||||||
|
final public function for(DateRange $range): SeriesResult
|
||||||
|
{
|
||||||
|
if (! $range->isBucketableByDay()) {
|
||||||
|
return SeriesResult::tooWide();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->series($range, $range->days());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $days
|
||||||
|
*/
|
||||||
|
abstract protected function series(DateRange $range, array $days): SeriesResult;
|
||||||
|
}
|
||||||
14
app/Dashboard/Stats/Series.php
Normal file
14
app/Dashboard/Stats/Series.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
class Series
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, int|float|null> $values
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly array $values,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
45
app/Dashboard/Stats/SeriesResult.php
Normal file
45
app/Dashboard/Stats/SeriesResult.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class SeriesResult
|
||||||
|
{
|
||||||
|
private bool $tooWide = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $labels
|
||||||
|
* @param array<int, Series> $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 === [];
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Dashboard/Stats/SeriesStat.php
Normal file
10
app/Dashboard/Stats/SeriesStat.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Support\DateRange;
|
||||||
|
|
||||||
|
interface SeriesStat extends Stat
|
||||||
|
{
|
||||||
|
public function for(DateRange $range): SeriesResult;
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,11 @@ public static function presets(): array
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isBucketableByDay(): bool
|
||||||
|
{
|
||||||
|
return $this->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.
|
* 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();
|
$cursor = $this->from->copy()->startOfDay();
|
||||||
$last = $this->to->copy()->startOfDay();
|
$last = $this->to->copy()->startOfDay();
|
||||||
|
|
||||||
if ($cursor->diffInDays($last) >= self::MAX_DAYS) {
|
if (! $this->isBucketableByDay()) {
|
||||||
throw new InvalidArgumentException(
|
throw new InvalidArgumentException(
|
||||||
'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.'
|
'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
121
tests/Unit/Dashboard/Stats/DailySeriesStatTest.php
Normal file
121
tests/Unit/Dashboard/Stats/DailySeriesStatTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\DailySeriesStat;
|
||||||
|
use App\Dashboard\Stats\Series;
|
||||||
|
use App\Dashboard\Stats\SeriesResult;
|
||||||
|
use App\Support\DateRange;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DailySeriesStatTest extends TestCase
|
||||||
|
{
|
||||||
|
private function bucketableRange(): DateRange
|
||||||
|
{
|
||||||
|
return new DateRange(
|
||||||
|
Carbon::parse('2026-07-01 00:00:00'),
|
||||||
|
Carbon::parse('2026-07-07 23:59:59'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_for_calls_series_and_returns_its_result_on_a_bucketable_range(): void
|
||||||
|
{
|
||||||
|
$range = $this->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<int, string>|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);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
tests/Unit/Dashboard/Stats/SeriesResultTest.php
Normal file
63
tests/Unit/Dashboard/Stats/SeriesResultTest.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\Series;
|
||||||
|
use App\Dashboard\Stats\SeriesResult;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SeriesResultTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_a_series_result_aligns_every_series_to_the_shared_label_axis(): void
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Dashboard\Stats\ArticlesPerFeed;
|
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||||
use App\Dashboard\Stats\BreakdownStat;
|
use App\Dashboard\Stats\BreakdownStat;
|
||||||
|
use App\Dashboard\Stats\SeriesStat;
|
||||||
use App\Dashboard\Stats\Stat;
|
use App\Dashboard\Stats\Stat;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -30,4 +31,9 @@ public function test_articles_per_feed_is_a_breakdown_stat(): void
|
||||||
{
|
{
|
||||||
$this->assertInstanceOf(BreakdownStat::class, new ArticlesPerFeed);
|
$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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,4 +183,47 @@ public function test_it_refuses_to_list_days_for_the_all_preset(): void
|
||||||
|
|
||||||
DateRange::preset('all')->days();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue