83 - Add a daily fetched-vs-published trend stat

This commit is contained in:
myrmidex 2026-08-12 23:29:36 +02:00
parent f6067eec38
commit 5b4e311d2d
2 changed files with 308 additions and 0 deletions

View file

@ -0,0 +1,51 @@
<?php
namespace App\Dashboard\Stats;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Support\DateRange;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class ArticlesTrend extends DailySeriesStat
{
public function key(): string
{
return 'articles-trend';
}
public function label(): string
{
return 'Articles Fetched vs Published';
}
protected function series(DateRange $range, array $days): SeriesResult
{
return new SeriesResult($days, [
new Series('Fetched', $this->countByDay(Article::query(), 'created_at', $range, $days)),
new Series('Published', $this->countByDay(ArticlePublication::query(), 'published_at', $range, $days)),
]);
}
/**
* @param Builder<covariant Model> $query
* @param array<int, string> $days
* @return array<int, int>
*/
private function countByDay(Builder $query, string $column, DateRange $range, array $days): array
{
/** @var array<string, int> $counts */
$counts = $query
->whereBetween($column, [$range->from, $range->to])
->selectRaw("DATE({$column}) as bucket, COUNT(*) as aggregate")
->groupBy('bucket')
->pluck('aggregate', 'bucket')
->all();
return array_map(
fn (string $day): int => (int) ($counts[$day] ?? 0),
$days,
);
}
}

View file

@ -0,0 +1,257 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\ArticlesTrend;
use App\Dashboard\Stats\DailySeriesStat;
use App\Dashboard\Stats\Series;
use App\Dashboard\Stats\SeriesResult;
use App\Enums\PublishStatusEnum;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Models\RouteArticle;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ArticlesTrendTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-07-15 12:00:00'));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function range(): DateRange
{
return new DateRange(
Carbon::parse('2026-07-01 00:00:00'),
Carbon::parse('2026-07-31 23:59:59'),
);
}
/**
* @return array<string, Series>
*/
private function seriesByName(SeriesResult $result): array
{
$out = [];
foreach ($result->series as $one) {
$out[$one->name] = $one;
}
return $out;
}
public function test_it_counts_articles_fetched_per_day(): void
{
Article::factory()->count(2)->create([
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-11 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$fetched = $this->seriesByName($result)['Fetched'];
$this->assertSame(2, $fetched->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-11', $days, true)]);
}
public function test_it_zero_fills_days_with_no_articles(): void
{
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-12 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$fetched = $this->seriesByName($result)['Fetched'];
$this->assertSame(0, $fetched->values[array_search('2026-07-11', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-12', $days, true)]);
}
public function test_it_excludes_articles_outside_the_range(): void
{
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-01 00:00:00'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-31 23:59:59'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-06-30 23:59:59'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-08-01 00:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$fetched = $this->seriesByName($result)['Fetched'];
$this->assertSame(2, array_sum($fetched->values));
}
public function test_it_counts_publications_per_day_from_article_publications(): void
{
$article = Article::factory()->create([
'created_at' => Carbon::parse('2026-01-01 00:00:00'),
]);
ArticlePublication::factory()->create([
'article_id' => $article->id,
'published_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
ArticlePublication::factory()->count(2)->create([
'article_id' => $article->id,
'published_at' => Carbon::parse('2026-07-11 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$published = $this->seriesByName($result)['Published'];
$this->assertSame(1, $published->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(2, $published->values[array_search('2026-07-11', $days, true)]);
}
public function test_it_does_not_source_the_published_series_from_route_article_publish_status(): void
{
$article = Article::factory()->create([
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
RouteArticle::factory()->create([
'article_id' => $article->id,
'publish_status' => PublishStatusEnum::PUBLISHED,
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$published = $this->seriesByName($result)['Published'];
$this->assertSame(0, array_sum($published->values));
}
public function test_it_counts_each_channel_publication_of_one_article_separately(): void
{
$article = Article::factory()->create([
'created_at' => Carbon::parse('2026-01-01 00:00:00'),
]);
$channels = PlatformChannel::factory()->count(3)->create();
foreach ($channels as $channel) {
ArticlePublication::factory()->create([
'article_id' => $article->id,
'platform_channel_id' => $channel->id,
'published_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
}
$result = (new ArticlesTrend)->for($this->range());
$published = $this->seriesByName($result)['Published'];
$this->assertSame(3, array_sum($published->values));
}
public function test_it_returns_both_series_over_one_shared_label_axis(): void
{
$result = (new ArticlesTrend)->for($this->range());
$this->assertCount(count($this->range()->days()), $result->labels);
$this->assertCount(2, $result->series);
$names = array_map(fn ($series) => $series->name, $result->series);
$this->assertSame(['Fetched', 'Published'], $names);
foreach ($result->series as $series) {
$this->assertCount(count($result->labels), $series->values);
}
}
public function test_it_splits_days_at_midnight(): void
{
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-10 23:59:59'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-11 00:00:01'),
]);
$publishedArticle = Article::factory()->create([
'created_at' => Carbon::parse('2026-01-01 00:00:00'),
]);
ArticlePublication::factory()->create([
'article_id' => $publishedArticle->id,
'published_at' => Carbon::parse('2026-07-10 23:59:59'),
]);
ArticlePublication::factory()->create([
'article_id' => $publishedArticle->id,
'published_at' => Carbon::parse('2026-07-11 00:00:01'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$fetched = $this->seriesByName($result)['Fetched'];
$published = $this->seriesByName($result)['Published'];
$this->assertSame(1, $fetched->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-11', $days, true)]);
$this->assertSame(2, array_sum($fetched->values));
$this->assertSame(1, $published->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $published->values[array_search('2026-07-11', $days, true)]);
$this->assertSame(2, array_sum($published->values));
}
public function test_it_is_a_daily_series_stat_and_reports_too_wide_without_querying(): void
{
$stat = new ArticlesTrend;
$this->assertInstanceOf(DailySeriesStat::class, $stat);
Article::factory()->create([
'created_at' => Carbon::now(),
]);
$queries = 0;
DB::listen(function () use (&$queries) {
$queries++;
});
$result = $stat->for(DateRange::preset('all'));
$this->assertTrue($result->isTooWide());
$this->assertSame(0, $queries);
}
}