From a6eff35586cef712f9bd05de67de203d278b8258 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 12 Aug 2026 00:00:49 +0200 Subject: [PATCH] 83 - Drive dashboard and API stats from a date range --- .../Api/V1/DashboardController.php | 34 ++--- app/Livewire/Dashboard.php | 123 ++++++++++++++++-- app/Services/DashboardStatsService.php | 57 ++------ resources/views/livewire/dashboard.blade.php | 75 ++++++++--- .../Api/V1/DashboardControllerTest.php | 106 +++++++++------ tests/Feature/Livewire/DashboardTest.php | 122 +++++++++++++++++ .../Services/DashboardStatsServiceTest.php | 97 ++++++++++---- 7 files changed, 458 insertions(+), 156 deletions(-) create mode 100644 tests/Feature/Livewire/DashboardTest.php diff --git a/app/Http/Controllers/Api/V1/DashboardController.php b/app/Http/Controllers/Api/V1/DashboardController.php index f6e7b8aa..e88704fb 100644 --- a/app/Http/Controllers/Api/V1/DashboardController.php +++ b/app/Http/Controllers/Api/V1/DashboardController.php @@ -2,10 +2,11 @@ namespace App\Http\Controllers\Api\V1; -use App\Models\Article; use App\Services\DashboardStatsService; +use App\Support\DateRange; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; class DashboardController extends BaseController { @@ -18,23 +19,26 @@ public function __construct( */ public function stats(Request $request): JsonResponse { - $period = $request->get('period', 'today'); + $validated = $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date', 'after_or_equal:from'], + ]); + + $range = isset($validated['from'], $validated['to']) + ? new DateRange( + Carbon::parse($validated['from'])->startOfDay(), + Carbon::parse($validated['to'])->endOfDay(), + ) + : DateRange::preset('today'); try { - // Get article stats from service - $articleStats = $this->dashboardStatsService->getStats($period); - - // Get system stats - $systemStats = $this->dashboardStatsService->getSystemStats(); - - // Get available periods - $availablePeriods = $this->dashboardStatsService->getAvailablePeriods(); - return $this->sendResponse([ - 'article_stats' => $articleStats, - 'system_stats' => $systemStats, - 'available_periods' => $availablePeriods, - 'current_period' => $period, + 'article_stats' => $this->dashboardStatsService->getStats($range), + 'system_stats' => $this->dashboardStatsService->getSystemStats(), + 'range' => [ + 'from' => $range->from->toDateString(), + 'to' => $range->to->toDateString(), + ], ]); } catch (\Exception $e) { return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500); diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index c65b6f83..6f111d81 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -3,35 +3,132 @@ namespace App\Livewire; use App\Services\DashboardStatsService; +use App\Support\DateRange; use Illuminate\Contracts\View\View; +use Illuminate\Support\Carbon; +use InvalidArgumentException; +use Livewire\Attributes\Computed; use Livewire\Component; class Dashboard extends Component { - public string $period = 'today'; + public string $from = ''; + + public string $to = ''; public function mount(): void { - // Default period + $this->applyPreset('today'); } - public function setPeriod(string $period): void + public function applyPreset(string $preset): void { - $this->period = $period; + try { + $range = DateRange::preset($preset); + } catch (InvalidArgumentException) { + return; + } + + $this->from = $range->from->toDateString(); + $this->to = $range->to->toDateString(); + } + + /** + * @return array + */ + public function presets(): array + { + return DateRange::presets(); + } + + public bool $rangeIsValid = true; + + public function range(): ?DateRange + { + $from = $this->parseBoundary($this->from); + $to = $this->parseBoundary($this->to); + + if (! $from instanceof Carbon || ! $to instanceof Carbon) { + return null; + } + + try { + return new DateRange($from->startOfDay(), $to->endOfDay()); + } catch (InvalidArgumentException) { + return null; + } + } + + private function parseBoundary(string $value): ?Carbon + { + try { + return Carbon::parse($value); + } catch (\Exception) { + return null; + } + } + + /** + * @return array + */ + #[Computed] + public function articleStats(): array + { + $range = $this->range(); + + if (! $range instanceof DateRange) { + return [ + 'articles_fetched' => 0, + 'articles_published' => 0, + 'published_percentage' => 0.0, + ]; + } + + return app(DashboardStatsService::class)->getStats($range); + } + + /** + * @return array + */ + #[Computed] + public function systemStats(): array + { + return app(DashboardStatsService::class)->getSystemStats(); + } + + public function updated(string $property): void + { + if (in_array($property, ['from', 'to'], true)) { + $this->validateRange(); + } + } + + private function validateRange(): void + { + $this->resetErrorBag(['from', 'to']); + + $from = $this->parseBoundary($this->from); + $to = $this->parseBoundary($this->to); + + if (! $from instanceof Carbon) { + $this->addError('from', 'The start date is not a valid date.'); + } + + if (! $to instanceof Carbon) { + $this->addError('to', 'The end date is not a valid date.'); + } + + if ($from instanceof Carbon && $to instanceof Carbon && $to->lessThan($from)) { + $this->addError('to', 'The end date must not be earlier than the start date.'); + } + + $this->rangeIsValid = $this->range() instanceof DateRange; } public function render(): View { - $service = app(DashboardStatsService::class); - - $articleStats = $service->getStats($this->period); - $systemStats = $service->getSystemStats(); - $availablePeriods = $service->getAvailablePeriods(); - return view('livewire.dashboard', [ - 'articleStats' => $articleStats, - 'systemStats' => $systemStats, - 'availablePeriods' => $availablePeriods, + 'presets' => $this->presets(), ])->layout('layouts.app'); } } diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index b1adf5a5..d9d636ba 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -8,33 +8,25 @@ use App\Models\PlatformAccount; use App\Models\PlatformChannel; use App\Models\Route; -use Carbon\Carbon; +use App\Support\DateRange; class DashboardStatsService { /** * @return array */ - public function getStats(string $period = 'today'): array + public function getStats(DateRange $range): array { - $dateRange = $this->getDateRange($period); + $bounds = [$range->from, $range->to]; - // Get articles fetched for the period - $articlesFetchedQuery = Article::query(); - if ($dateRange) { - $articlesFetchedQuery->whereBetween('created_at', $dateRange); - } - $articlesFetched = $articlesFetchedQuery->count(); + $articlesFetched = Article::query() + ->whereBetween('created_at', $bounds) + ->count(); - // Get articles published for the period - $articlesPublishedQuery = ArticlePublication::query() - ->whereNotNull('published_at'); - if ($dateRange) { - $articlesPublishedQuery->whereBetween('published_at', $dateRange); - } - $articlesPublished = $articlesPublishedQuery->count(); + $articlesPublished = ArticlePublication::query() + ->whereBetween('published_at', $bounds) + ->count(); - // Calculate published percentage $publishedPercentage = $articlesFetched > 0 ? round(($articlesPublished / $articlesFetched) * 100, 1) : 0.0; return [ @@ -44,37 +36,6 @@ public function getStats(string $period = 'today'): array ]; } - /** - * @return array - */ - public function getAvailablePeriods(): array - { - return [ - 'today' => 'Today', - 'week' => 'This Week', - 'month' => 'This Month', - 'year' => 'This Year', - 'all' => 'All Time', - ]; - } - - /** - * @return array{0: Carbon, 1: Carbon}|null - */ - private function getDateRange(string $period): ?array - { - $now = Carbon::now(); - - return match ($period) { - 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], - 'week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()], - 'month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()], - 'year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()], - 'all' => null, // No date filtering for all-time stats - default => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], - }; - } - /** * @return array */ diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index b1bc06e5..77597051 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -16,8 +16,8 @@

Active Feeds

- {{ $systemStats['active_feeds'] }} - /{{ $systemStats['total_feeds'] }} + {{ $this->systemStats['active_feeds'] }} + /{{ $this->systemStats['total_feeds'] }}

@@ -34,8 +34,8 @@

Platform Accounts

- {{ $systemStats['active_platform_accounts'] }} - /{{ $systemStats['total_platform_accounts'] }} + {{ $this->systemStats['active_platform_accounts'] }} + /{{ $this->systemStats['total_platform_accounts'] }}

@@ -52,8 +52,8 @@

Platform Channels

- {{ $systemStats['active_platform_channels'] }} - /{{ $systemStats['total_platform_channels'] }} + {{ $this->systemStats['active_platform_channels'] }} + /{{ $this->systemStats['total_platform_channels'] }}

@@ -70,8 +70,8 @@

Active Routes

- {{ $systemStats['active_routes'] }} - /{{ $systemStats['total_routes'] }} + {{ $this->systemStats['active_routes'] }} + /{{ $this->systemStats['total_routes'] }}

@@ -81,18 +81,51 @@
-
+

Article Statistics

- + +
+
+ @foreach ($presets as $value => $label) + + @endforeach +
+ +
+
+ + +
+
+ + +
+
+
-
+ + @error('from') +

{{ $message }}

+ @enderror + @error('to') +

{{ $message }}

+ @enderror + +
! $rangeIsValid])>
@@ -104,7 +137,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin

Articles Fetched

- {{ $articleStats['articles_fetched'] }} + {{ $this->articleStats['articles_fetched'] }}

@@ -121,7 +154,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin

Articles Published

- {{ $articleStats['articles_published'] }} + {{ $this->articleStats['articles_published'] }}

@@ -138,7 +171,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin

Published Rate

- {{ $articleStats['published_percentage'] }}% + {{ $this->articleStats['published_percentage'] }}%

diff --git a/tests/Feature/Http/Controllers/Api/V1/DashboardControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/DashboardControllerTest.php index 6a3ed633..85e1f2e1 100644 --- a/tests/Feature/Http/Controllers/Api/V1/DashboardControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/DashboardControllerTest.php @@ -8,6 +8,7 @@ use App\Models\PlatformChannel; use App\Models\Route; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Tests\TestCase; class DashboardControllerTest extends TestCase @@ -35,72 +36,103 @@ public function test_stats_returns_successful_response(): void 'total_routes', 'active_routes', ], - 'available_periods', - 'current_period', + 'range' => ['from', 'to'], ], 'message', ]); } - public function test_stats_with_different_periods(): void + public function test_stats_no_longer_exposes_named_periods(): void { - $periods = ['today', 'week', 'month', 'year', 'all']; + $this + ->getJson('/api/v1/dashboard/stats') + ->assertStatus(200) + ->assertJsonMissingPath('data.available_periods') + ->assertJsonMissingPath('data.current_period'); + } - foreach ($periods as $period) { - $this - ->getJson("/api/v1/dashboard/stats?period={$period}") - ->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'current_period' => $period, - ], - ]); - } + public function test_stats_honours_an_explicit_from_and_to_range(): void + { + Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]); + Article::factory()->create(['created_at' => Carbon::parse('2026-08-05 12:00:00')]); + + $this + ->getJson('/api/v1/dashboard/stats?from=2026-07-01&to=2026-07-31') + ->assertStatus(200) + ->assertJson([ + 'data' => [ + 'article_stats' => ['articles_fetched' => 2], + 'range' => ['from' => '2026-07-01', 'to' => '2026-07-31'], + ], + ]); + } + + public function test_stats_defaults_to_the_current_day_without_params(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + Article::factory()->create(['created_at' => Carbon::parse('2026-07-15 09:00:00')]); + Article::factory()->create(['created_at' => Carbon::parse('2026-07-14 09:00:00')]); + + $this + ->getJson('/api/v1/dashboard/stats') + ->assertStatus(200) + ->assertJson([ + 'data' => [ + 'article_stats' => ['articles_fetched' => 1], + 'range' => ['from' => '2026-07-15', 'to' => '2026-07-15'], + ], + ]); + } + + public function test_stats_rejects_an_inverted_range(): void + { + $this + ->getJson('/api/v1/dashboard/stats?from=2026-07-31&to=2026-07-01') + ->assertStatus(422); + } + + public function test_stats_rejects_a_malformed_date(): void + { + $this + ->getJson('/api/v1/dashboard/stats?from=not-a-date&to=2026-07-01') + ->assertStatus(422); } public function test_stats_with_sample_data(): void { - // Get initial counts - $initialArticles = Article::count(); - $initialFeeds = Feed::count(); - $initialChannels = PlatformChannel::count(); - $initialRoutes = Route::count(); - $initialPublications = ArticlePublication::count(); - - // Create test data $feed = Feed::factory()->create(['is_active' => true]); $channel = PlatformChannel::factory()->create(['is_active' => true]); - $route = Route::factory()->create(['is_active' => true]); + Route::factory()->create(['is_active' => true]); - // Create articles - $articles = Article::factory()->count(3)->create(['feed_id' => $feed->id]); + $articles = Article::factory()->count(3)->create([ + 'feed_id' => $feed->id, + 'created_at' => Carbon::parse('2026-07-10 12:00:00'), + ]); - // Publish one article ArticlePublication::factory()->create([ 'article_id' => $articles->first()->id, 'platform_channel_id' => $channel->id, - 'published_at' => now(), + 'published_at' => Carbon::parse('2026-07-10 13:00:00'), ]); - $response = $this->getJson('/api/v1/dashboard/stats?period=all'); + $response = $this->getJson('/api/v1/dashboard/stats?from=2026-07-01&to=2026-07-31'); $response->assertStatus(200) ->assertJson([ 'success' => true, 'data' => [ 'article_stats' => [ - 'articles_fetched' => $initialArticles + 3, - 'articles_published' => $initialPublications + 1, + 'articles_fetched' => 3, + 'articles_published' => 1, + ], + 'system_stats' => [ + 'total_feeds' => Feed::count(), + 'total_platform_channels' => PlatformChannel::count(), + 'total_routes' => Route::count(), ], ], ]); - - // Just verify structure and that we have more items than we started with - $responseData = $response->json('data'); - $this->assertGreaterThanOrEqual($initialFeeds + 1, $responseData['system_stats']['total_feeds']); - $this->assertGreaterThanOrEqual($initialChannels + 1, $responseData['system_stats']['total_platform_channels']); - $this->assertGreaterThanOrEqual($initialRoutes + 1, $responseData['system_stats']['total_routes']); } public function test_stats_returns_empty_data_with_no_records(): void diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php new file mode 100644 index 00000000..425bb03f --- /dev/null +++ b/tests/Feature/Livewire/DashboardTest.php @@ -0,0 +1,122 @@ +assertSet('from', '2026-07-15') + ->assertSet('to', '2026-07-15'); + } + + public function test_it_recomputes_stats_when_the_range_changes(): void + { + Article::factory()->count(3)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]); + + Livewire::test(Dashboard::class) + ->set('from', '2026-07-01') + ->set('to', '2026-07-31') + ->assertSee('3') + ->set('from', '2026-08-01') + ->set('to', '2026-08-31') + ->assertSee('0'); + } + + public function test_it_populates_the_range_when_a_preset_is_applied(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + Livewire::test(Dashboard::class) + ->call('applyPreset', 'month') + ->assertSet('from', '2026-07-01') + ->assertSet('to', '2026-07-31'); + } + + public function test_it_applies_the_all_preset_as_a_bounded_range(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + Livewire::test(Dashboard::class) + ->call('applyPreset', 'all') + ->assertSet('to', '2026-07-15'); + } + + public function test_it_ignores_an_unknown_preset(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + Livewire::test(Dashboard::class) + ->call('applyPreset', 'fortnight') + ->assertSet('from', '2026-07-15') + ->assertSet('to', '2026-07-15') + ->assertHasNoErrors(); + } + + public function test_it_reports_an_inverted_range_against_the_end_date(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + Livewire::test(Dashboard::class) + ->set('from', '2026-07-31') + ->set('to', '2026-07-01') + ->assertHasErrors('to'); + } + + public function test_it_blames_the_start_date_when_the_start_date_is_malformed(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + Livewire::test(Dashboard::class) + ->set('from', 'not-a-date') + ->assertHasErrors('from') + ->assertHasNoErrors('to'); + } + + public function test_it_blames_the_end_date_when_the_end_date_is_malformed(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + Livewire::test(Dashboard::class) + ->set('to', 'not-a-date') + ->assertHasErrors('to') + ->assertHasNoErrors('from'); + } + + public function test_it_reports_zero_stats_for_an_invalid_range(): void + { + Article::factory()->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]); + + Livewire::test(Dashboard::class) + ->set('from', '2026-07-01') + ->set('to', 'not-a-date') + ->assertSet('rangeIsValid', false); + } + + public function test_it_marks_a_well_formed_range_as_valid(): void + { + Livewire::test(Dashboard::class) + ->set('from', '2026-07-01') + ->set('to', '2026-07-31') + ->assertSet('rangeIsValid', true); + } + + public function test_it_renders_the_preset_buttons(): void + { + Livewire::test(Dashboard::class) + ->assertSee('This Month') + ->assertSee('All Time'); + } +} diff --git a/tests/Unit/Services/DashboardStatsServiceTest.php b/tests/Unit/Services/DashboardStatsServiceTest.php index d84b379d..6652d088 100644 --- a/tests/Unit/Services/DashboardStatsServiceTest.php +++ b/tests/Unit/Services/DashboardStatsServiceTest.php @@ -2,41 +2,94 @@ namespace Tests\Unit\Services; +use App\Models\Article; +use App\Models\ArticlePublication; use App\Services\DashboardStatsService; -use Illuminate\Support\Facades\Http; +use App\Support\DateRange; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Tests\TestCase; class DashboardStatsServiceTest extends TestCase { - protected function setUp(): void - { - parent::setUp(); + use RefreshDatabase; - // Mock HTTP requests to prevent external calls - Http::fake([ - '*' => Http::response('', 500), - ]); + private function service(): DashboardStatsService + { + return new DashboardStatsService; } - public function test_get_available_periods_returns_correct_options(): void + public function test_it_counts_articles_fetched_within_the_range(): void { - $service = new DashboardStatsService; - $periods = $service->getAvailablePeriods(); + Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]); + Article::factory()->create(['created_at' => Carbon::parse('2026-08-01 12:00:00')]); - $this->assertIsArray($periods); - $this->assertArrayHasKey('today', $periods); - $this->assertArrayHasKey('week', $periods); - $this->assertArrayHasKey('month', $periods); - $this->assertArrayHasKey('year', $periods); - $this->assertArrayHasKey('all', $periods); + $stats = $this->service()->getStats(new DateRange( + Carbon::parse('2026-07-01 00:00:00'), + Carbon::parse('2026-07-31 23:59:59'), + )); - $this->assertEquals('Today', $periods['today']); - $this->assertEquals('All Time', $periods['all']); + $this->assertSame(2, $stats['articles_fetched']); } - public function test_service_instantiation(): void + public function test_it_counts_articles_published_within_the_range(): void { - $service = new DashboardStatsService; - $this->assertInstanceOf(DashboardStatsService::class, $service); + ArticlePublication::factory()->create(['published_at' => Carbon::parse('2026-07-10 12:00:00')]); + ArticlePublication::factory()->create(['published_at' => Carbon::parse('2026-08-01 12:00:00')]); + + $stats = $this->service()->getStats(new DateRange( + Carbon::parse('2026-07-01 00:00:00'), + Carbon::parse('2026-07-31 23:59:59'), + )); + + $this->assertSame(1, $stats['articles_published']); + } + + public function test_it_calculates_the_published_percentage(): void + { + $range = new DateRange( + Carbon::parse('2026-07-01 00:00:00'), + Carbon::parse('2026-07-31 23:59:59'), + ); + + Article::factory()->count(4)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]); + ArticlePublication::factory()->create(['published_at' => Carbon::parse('2026-07-10 12:00:00')]); + + $stats = $this->service()->getStats($range); + + $this->assertSame(25.0, $stats['published_percentage']); + } + + public function test_it_reports_a_zero_percentage_when_nothing_was_fetched(): void + { + $stats = $this->service()->getStats(DateRange::preset('all')); + + $this->assertSame(0, $stats['articles_fetched']); + $this->assertSame(0.0, $stats['published_percentage']); + } + + public function test_it_excludes_records_outside_the_range_entirely(): void + { + 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')]); + + $stats = $this->service()->getStats(new DateRange( + Carbon::parse('2026-07-01 00:00:00'), + Carbon::parse('2026-07-31 23:59:59'), + )); + + $this->assertSame(0, $stats['articles_fetched']); + } + + public function test_it_counts_everything_for_the_all_preset(): void + { + Carbon::setTestNow('2026-07-15 12:00:00'); + + Article::factory()->create(['created_at' => Carbon::parse('2020-01-01 00:00:00')]); + Article::factory()->create(['created_at' => Carbon::parse('2026-07-15 11:00:00')]); + + $stats = $this->service()->getStats(DateRange::preset('all')); + + $this->assertSame(2, $stats['articles_fetched']); } }