diff --git a/app/Http/Controllers/Api/V1/RouteArticlesController.php b/app/Http/Controllers/Api/V1/RouteArticlesController.php
index b41763b3..cbe111e6 100644
--- a/app/Http/Controllers/Api/V1/RouteArticlesController.php
+++ b/app/Http/Controllers/Api/V1/RouteArticlesController.php
@@ -71,7 +71,10 @@ public function reject(RouteArticle $routeArticle): JsonResponse
public function restore(RouteArticle $routeArticle): JsonResponse
{
try {
- $routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
+ $routeArticle->update([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
@@ -88,7 +91,10 @@ public function clear(): JsonResponse
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
- ->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
+ ->update([
+ 'approval_status' => ApprovalStatusEnum::REJECTED,
+ 'decided_at' => now(),
+ ]);
return $this->sendResponse(
['rejected_count' => $count],
diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php
index 1f999708..ea21884d 100644
--- a/app/Livewire/Articles.php
+++ b/app/Livewire/Articles.php
@@ -74,13 +74,19 @@ public function reject(int $routeArticleId): void
public function restore(int $routeArticleId): void
{
$routeArticle = RouteArticle::findOrFail($routeArticleId);
- $routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
+ $routeArticle->update([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
}
public function clear(): void
{
$feed = $this->feedId !== null ? Feed::find($this->feedId) : null;
- $cleared = $this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
+ $cleared = $this->clearableQuery()->update([
+ 'approval_status' => ApprovalStatusEnum::REJECTED,
+ 'decided_at' => now(),
+ ]);
if ($cleared > 0) {
ActivityLogged::dispatch(
diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php
index 0d4c1723..eb520405 100644
--- a/app/Livewire/Dashboard.php
+++ b/app/Livewire/Dashboard.php
@@ -24,7 +24,7 @@ class Dashboard extends Component
public function mount(): void
{
- $this->applyPreset('today');
+ $this->applyPreset('month');
}
/**
diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php
index 885cd3ad..2af93d95 100644
--- a/app/Models/RouteArticle.php
+++ b/app/Models/RouteArticle.php
@@ -108,7 +108,10 @@ public function approve(): void
return;
}
- $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
+ $this->update([
+ 'approval_status' => ApprovalStatusEnum::APPROVED,
+ 'decided_at' => now(),
+ ]);
ActivityLogged::dispatch(
ActivityTypeEnum::APPROVE,
@@ -126,7 +129,10 @@ public function reject(): void
return;
}
- $this->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
+ $this->update([
+ 'approval_status' => ApprovalStatusEnum::REJECTED,
+ 'decided_at' => now(),
+ ]);
ActivityLogged::dispatch(
ActivityTypeEnum::REJECT,
diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php
index b79359c3..1387ce92 100644
--- a/app/Services/Article/ValidationService.php
+++ b/app/Services/Article/ValidationService.php
@@ -83,6 +83,7 @@ private function createRouteArticles(Article $article, string $content): void
[
'approval_status' => $status,
'validated_at' => now(),
+ 'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
]
);
}
diff --git a/database/factories/RouteArticleFactory.php b/database/factories/RouteArticleFactory.php
index 0230d5a7..4b3e7b2f 100644
--- a/database/factories/RouteArticleFactory.php
+++ b/database/factories/RouteArticleFactory.php
@@ -62,6 +62,7 @@ public function pending(): static
{
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
]);
}
@@ -70,6 +71,7 @@ public function approved(): static
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::APPROVED,
'validated_at' => now(),
+ 'decided_at' => now(),
]);
}
@@ -78,6 +80,7 @@ public function rejected(): static
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::REJECTED,
'validated_at' => now(),
+ 'decided_at' => now(),
]);
}
}
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php
index d018fb1e..96e8b52e 100644
--- a/resources/views/livewire/dashboard.blade.php
+++ b/resources/views/livewire/dashboard.blade.php
@@ -183,40 +183,40 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland
-
-
-
Fetched vs Published
+
+
+
+
Fetched vs Published
- @island('articles-trend')
- @include('livewire.partials.trend-panel', [
- 'result' => $this->articlesTrend,
- 'emptyMessage' => 'No articles or publications in this range.',
- 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
- ])
- @endisland
-
+ @island('articles-trend')
+ @include('livewire.partials.trend-panel', [
+ 'result' => $this->articlesTrend,
+ 'emptyMessage' => 'No articles or publications in this range.',
+ 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
+ ])
+ @endisland
+
-
-
-
Articles per Feed
+
+
Articles per Feed
- @island('articles-per-feed')
- @include('livewire.partials.breakdown-panel', [
- 'result' => $this->articlesPerFeed,
- 'emptyMessage' => 'No feeds are configured yet.',
- ])
- @endisland
-
+ @island('articles-per-feed')
+ @include('livewire.partials.breakdown-panel', [
+ 'result' => $this->articlesPerFeed,
+ 'emptyMessage' => 'No feeds are configured yet.',
+ ])
+ @endisland
+
-
-
-
Publications per Channel
+
+
Publications per Channel
- @island('publications-per-channel')
- @include('livewire.partials.breakdown-panel', [
- 'result' => $this->publicationsPerChannel,
- 'emptyMessage' => 'No channels are configured yet.',
- ])
- @endisland
+ @island('publications-per-channel')
+ @include('livewire.partials.breakdown-panel', [
+ 'result' => $this->publicationsPerChannel,
+ 'emptyMessage' => 'No channels are configured yet.',
+ ])
+ @endisland
+
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php
index 4ec2b437..83044f18 100644
--- a/tests/Feature/Livewire/DashboardTest.php
+++ b/tests/Feature/Livewire/DashboardTest.php
@@ -16,13 +16,13 @@ class DashboardTest extends TestCase
{
use RefreshDatabase;
- public function test_it_defaults_to_the_current_day(): void
+ public function test_it_defaults_to_the_current_month(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
Livewire::test(Dashboard::class)
- ->assertSet('from', '2026-07-15')
- ->assertSet('to', '2026-07-15');
+ ->assertSet('from', '2026-07-01')
+ ->assertSet('to', '2026-07-31');
}
public function test_it_recomputes_stats_when_the_range_changes(): void
@@ -63,8 +63,8 @@ public function test_it_ignores_an_unknown_preset(): void
Livewire::test(Dashboard::class)
->call('applyPreset', 'fortnight')
- ->assertSet('from', '2026-07-15')
- ->assertSet('to', '2026-07-15')
+ ->assertSet('from', '2026-07-01')
+ ->assertSet('to', '2026-07-31')
->assertHasNoErrors();
}
diff --git a/tests/Feature/RouteArticleDecisionStampingTest.php b/tests/Feature/RouteArticleDecisionStampingTest.php
new file mode 100644
index 00000000..8fffadc2
--- /dev/null
+++ b/tests/Feature/RouteArticleDecisionStampingTest.php
@@ -0,0 +1,174 @@
+count(3)->create([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
+
+ Livewire::test(Articles::class)->call('clear');
+
+ foreach ($pending as $routeArticle) {
+ /** @var RouteArticle $fresh */
+ $fresh = $routeArticle->fresh();
+
+ $this->assertNotNull($fresh->decided_at);
+ }
+ }
+
+ public function test_the_api_clear_endpoint_stamps_every_rejected_row(): void
+ {
+ $pending = RouteArticle::factory()->count(3)->create([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
+
+ $this->actingAs(User::factory()->create())
+ ->postJson('/api/v1/route-articles/clear')
+ ->assertOk();
+
+ foreach ($pending as $routeArticle) {
+ /** @var RouteArticle $fresh */
+ $fresh = $routeArticle->fresh();
+
+ $this->assertNotNull($fresh->decided_at);
+ }
+ }
+
+ public function test_restoring_to_pending_clears_the_decision_time(): void
+ {
+ /** @var RouteArticle $routeArticle */
+ $routeArticle = RouteArticle::factory()->create([
+ 'approval_status' => ApprovalStatusEnum::REJECTED,
+ 'decided_at' => now(),
+ ]);
+
+ Livewire::test(Articles::class)->call('restore', $routeArticle->id);
+
+ $this->assertNull($routeArticle->fresh()->decided_at);
+ }
+
+ public function test_the_api_restore_endpoint_clears_the_decision_time(): void
+ {
+ /** @var RouteArticle $routeArticle */
+ $routeArticle = RouteArticle::factory()->create([
+ 'approval_status' => ApprovalStatusEnum::REJECTED,
+ 'decided_at' => now(),
+ ]);
+
+ $this->actingAs(User::factory()->create())
+ ->postJson("/api/v1/route-articles/{$routeArticle->id}/restore")
+ ->assertOk();
+
+ $this->assertNull($routeArticle->fresh()->decided_at);
+ }
+
+ public function test_auto_approved_articles_are_stamped_on_creation(): void
+ {
+ $article = $this->articleOnRouteWithoutKeywords(autoApprove: true);
+
+ $this->validate($article);
+
+ $routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
+
+ $this->assertSame(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status);
+ $this->assertNotNull($routeArticle->decided_at);
+ }
+
+ public function test_keyword_rejected_articles_are_stamped_on_creation(): void
+ {
+ $article = $this->articleOnRouteWithKeyword('nothing-that-matches');
+
+ $this->validate($article);
+
+ $routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
+
+ $this->assertSame(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status);
+ $this->assertNotNull($routeArticle->decided_at);
+ }
+
+ public function test_pending_articles_are_not_stamped_on_creation(): void
+ {
+ $article = $this->articleOnRouteWithoutKeywords(autoApprove: false);
+
+ $this->validate($article);
+
+ $routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
+
+ $this->assertSame(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
+ $this->assertNull($routeArticle->decided_at);
+ }
+
+ private function validate(Article $article): void
+ {
+ $fetcher = Mockery::mock(ArticleFetcher::class);
+ $fetcher->shouldReceive('fetchArticleData')
+ ->with($article)
+ ->once()
+ ->andReturn([
+ 'title' => 'Test Title',
+ 'description' => 'Test description',
+ 'full_article' => 'Body text',
+ ]);
+
+ (new ValidationService($fetcher))->validate($article);
+ }
+
+ private function articleOnRouteWithoutKeywords(bool $autoApprove): Article
+ {
+ $feed = Feed::factory()->create();
+ $channel = PlatformChannel::factory()->create();
+
+ Route::factory()->create([
+ 'feed_id' => $feed->id,
+ 'platform_channel_id' => $channel->id,
+ 'is_active' => true,
+ 'auto_approve' => $autoApprove,
+ ]);
+
+ return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]);
+ }
+
+ private function articleOnRouteWithKeyword(string $keyword): Article
+ {
+ $feed = Feed::factory()->create();
+ $channel = PlatformChannel::factory()->create();
+
+ Route::factory()->create([
+ 'feed_id' => $feed->id,
+ 'platform_channel_id' => $channel->id,
+ 'is_active' => true,
+ ]);
+
+ Keyword::factory()->active()->create([
+ 'feed_id' => $feed->id,
+ 'platform_channel_id' => $channel->id,
+ 'keyword' => $keyword,
+ ]);
+
+ return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]);
+ }
+}
diff --git a/tests/Unit/Models/RouteArticleDecisionTest.php b/tests/Unit/Models/RouteArticleDecisionTest.php
new file mode 100644
index 00000000..7c4f89e5
--- /dev/null
+++ b/tests/Unit/Models/RouteArticleDecisionTest.php
@@ -0,0 +1,86 @@
+create([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
+
+ $this->freezeTime(function () use ($routeArticle) {
+ $routeArticle->approve();
+
+ $this->assertSame(
+ now()->format('Y-m-d H:i:s'),
+ $routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
+ );
+ });
+ }
+
+ public function test_rejecting_stamps_the_decision_time(): void
+ {
+ /** @var RouteArticle $routeArticle */
+ $routeArticle = RouteArticle::factory()->create([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
+
+ $this->freezeTime(function () use ($routeArticle) {
+ $routeArticle->reject();
+
+ $this->assertSame(
+ now()->format('Y-m-d H:i:s'),
+ $routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
+ );
+ });
+ }
+
+ public function test_re_approving_does_not_move_the_decision_time(): void
+ {
+ /** @var RouteArticle $routeArticle */
+ $routeArticle = RouteArticle::factory()->create([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
+
+ $routeArticle->approve();
+ $first = $routeArticle->fresh()->decided_at;
+
+ $this->travel(1)->hours();
+ $routeArticle->fresh()->approve();
+
+ $this->assertSame(
+ $first->format('Y-m-d H:i:s'),
+ $routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
+ );
+ }
+
+ public function test_rejecting_an_approved_article_moves_the_decision_time(): void
+ {
+ /** @var RouteArticle $routeArticle */
+ $routeArticle = RouteArticle::factory()->create([
+ 'approval_status' => ApprovalStatusEnum::PENDING,
+ 'decided_at' => null,
+ ]);
+
+ $routeArticle->approve();
+ $first = $routeArticle->fresh()->decided_at;
+
+ $this->travel(1)->hours();
+ $routeArticle->fresh()->reject();
+
+ $this->assertTrue($routeArticle->fresh()->decided_at->greaterThan($first));
+ }
+}