From 5b4e311d2d7d9691642281a25b8a6c86730e379d Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 12 Aug 2026 23:29:36 +0200 Subject: [PATCH] 83 - Add a daily fetched-vs-published trend stat --- app/Dashboard/Stats/ArticlesTrend.php | 51 ++++ .../Dashboard/Stats/ArticlesTrendTest.php | 257 ++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 app/Dashboard/Stats/ArticlesTrend.php create mode 100644 tests/Unit/Dashboard/Stats/ArticlesTrendTest.php diff --git a/app/Dashboard/Stats/ArticlesTrend.php b/app/Dashboard/Stats/ArticlesTrend.php new file mode 100644 index 00000000..e1abffcf --- /dev/null +++ b/app/Dashboard/Stats/ArticlesTrend.php @@ -0,0 +1,51 @@ +countByDay(Article::query(), 'created_at', $range, $days)), + new Series('Published', $this->countByDay(ArticlePublication::query(), 'published_at', $range, $days)), + ]); + } + + /** + * @param Builder $query + * @param array $days + * @return array + */ + private function countByDay(Builder $query, string $column, DateRange $range, array $days): array + { + /** @var array $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, + ); + } +} diff --git a/tests/Unit/Dashboard/Stats/ArticlesTrendTest.php b/tests/Unit/Dashboard/Stats/ArticlesTrendTest.php new file mode 100644 index 00000000..8b6fefb3 --- /dev/null +++ b/tests/Unit/Dashboard/Stats/ArticlesTrendTest.php @@ -0,0 +1,257 @@ + + */ + 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); + } +}