83 - Add a daily fetched-vs-published trend stat
This commit is contained in:
parent
f6067eec38
commit
5b4e311d2d
2 changed files with 308 additions and 0 deletions
51
app/Dashboard/Stats/ArticlesTrend.php
Normal file
51
app/Dashboard/Stats/ArticlesTrend.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
tests/Unit/Dashboard/Stats/ArticlesTrendTest.php
Normal file
257
tests/Unit/Dashboard/Stats/ArticlesTrendTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue