Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
10 changed files with 331 additions and 1 deletions
Showing only changes of commit f6067eec38 - Show all commits

1
.gitignore vendored
View file

@ -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*

View 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;
}

View 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,
) {}
}

View 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 === [];
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Dashboard\Stats;
use App\Support\DateRange;
interface SeriesStat extends Stat
{
public function for(DateRange $range): SeriesResult;
}

View file

@ -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.'
); );

View 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);
}
}

View 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());
}
}

View file

@ -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));
}
} }

View file

@ -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();
}
} }