83 - Tighten dashboard analytics after review
This commit is contained in:
parent
42fab63eaf
commit
a8d96554a7
10 changed files with 82 additions and 39 deletions
|
|
@ -22,9 +22,4 @@ public function shareOf(Breakdown $row): float
|
||||||
|
|
||||||
return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0;
|
return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isEmpty(): bool
|
|
||||||
{
|
|
||||||
return $this->total() === 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
interface Stat
|
interface Stat
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Stable identifier, also used as the island name for this stat's panel.
|
* Stable identifier. Island names in dashboard.blade.php are written to match by hand, not derived from this.
|
||||||
*/
|
*/
|
||||||
public function key(): string;
|
public function key(): string;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ public function __construct(
|
||||||
public function stats(Request $request): JsonResponse
|
public function stats(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'from' => ['nullable', 'date'],
|
'from' => ['nullable', 'date', 'required_with:to'],
|
||||||
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
'to' => ['nullable', 'date', 'after_or_equal:from', 'required_with:from'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$range = isset($validated['from'], $validated['to'])
|
$range = isset($validated['from'], $validated['to'])
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,9 @@
|
||||||
|
|
||||||
use App\Enums\ActivityTypeEnum;
|
use App\Enums\ActivityTypeEnum;
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
use App\Models\Article;
|
|
||||||
use App\Services\Activity\ActivitySummary;
|
use App\Services\Activity\ActivitySummary;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
|
@ -37,11 +34,7 @@ public function updatedDays(): void
|
||||||
private function query(): Builder
|
private function query(): Builder
|
||||||
{
|
{
|
||||||
return ActivityLog::query()
|
return ActivityLog::query()
|
||||||
->with(['subject' => function (Relation $morphTo): void {
|
->withSubjectDetails()
|
||||||
if ($morphTo instanceof MorphTo) {
|
|
||||||
$morphTo->morphWith([Article::class => ['feed']]);
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
->since(now()->subDays($this->days))
|
->since(now()->subDays($this->days))
|
||||||
->when(
|
->when(
|
||||||
$this->type !== '',
|
$this->type !== '',
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,10 @@
|
||||||
use App\Dashboard\Stats\PublishSuccessRate;
|
use App\Dashboard\Stats\PublishSuccessRate;
|
||||||
use App\Dashboard\Stats\SeriesResult;
|
use App\Dashboard\Stats\SeriesResult;
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
use App\Models\Article;
|
|
||||||
use App\Services\DashboardStatsService;
|
use App\Services\DashboardStatsService;
|
||||||
use App\Support\DateRange;
|
use App\Support\DateRange;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
|
@ -195,11 +192,7 @@ private function breakdown(string $stat): BreakdownResult
|
||||||
public function recentActivity(): Collection
|
public function recentActivity(): Collection
|
||||||
{
|
{
|
||||||
return ActivityLog::query()
|
return ActivityLog::query()
|
||||||
->with(['subject' => function (Relation $morphTo): void {
|
->withSubjectDetails()
|
||||||
if ($morphTo instanceof MorphTo) {
|
|
||||||
$morphTo->morphWith([Article::class => ['feed']]);
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
->latestFirst()
|
->latestFirst()
|
||||||
->limit(self::RECENT_ACTIVITY_LIMIT)
|
->limit(self::RECENT_ACTIVITY_LIMIT)
|
||||||
->get();
|
->get();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -77,4 +78,17 @@ public function scopeLatestFirst(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->orderByDesc('logged_at')->orderByDesc('id');
|
return $query->orderByDesc('logged_at')->orderByDesc('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<ActivityLog> $query
|
||||||
|
* @return Builder<ActivityLog>
|
||||||
|
*/
|
||||||
|
public function scopeWithSubjectDetails(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->with(['subject' => function (Relation $morphTo): void {
|
||||||
|
if ($morphTo instanceof MorphTo) {
|
||||||
|
$morphTo->morphWith([Article::class => ['feed']]);
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,22 @@ public function test_stats_rejects_a_malformed_date(): void
|
||||||
->assertStatus(422);
|
->assertStatus(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_stats_rejects_a_from_without_a_to(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->getJson('/api/v1/dashboard/stats?from=2026-07-01')
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors('to');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stats_rejects_a_to_without_a_from(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->getJson('/api/v1/dashboard/stats?to=2026-07-31')
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors('from');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_stats_with_sample_data(): void
|
public function test_stats_with_sample_data(): void
|
||||||
{
|
{
|
||||||
$feed = Feed::factory()->create(['is_active' => true]);
|
$feed = Feed::factory()->create(['is_active' => true]);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
use App\Models\RouteArticle;
|
use App\Models\RouteArticle;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
@ -552,6 +553,32 @@ public function test_it_renders_an_activity_entry_whose_subject_was_deleted(): v
|
||||||
Livewire::test(Dashboard::class)->assertSee('Orphaned entry');
|
Livewire::test(Dashboard::class)->assertSee('Orphaned entry');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function dashboardQueryCount(int $activityEntries): int
|
||||||
|
{
|
||||||
|
$feed = Feed::factory()->create();
|
||||||
|
|
||||||
|
foreach (range(1, $activityEntries) as $index) {
|
||||||
|
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||||
|
ActivityLog::factory()->forSubject($article)->create(['message' => "Entry {$index}"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
Livewire::test(Dashboard::class);
|
||||||
|
$queries = count(DB::getQueryLog());
|
||||||
|
DB::disableQueryLog();
|
||||||
|
|
||||||
|
return $queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_does_not_fan_out_queries_per_activity_entry(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
$this->dashboardQueryCount(1),
|
||||||
|
$this->dashboardQueryCount(20),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_every_range_dependent_island_re_renders_on_a_range_change(): void
|
public function test_every_range_dependent_island_re_renders_on_a_range_change(): void
|
||||||
{
|
{
|
||||||
$fragments = $this->islandFragments(
|
$fragments = $this->islandFragments(
|
||||||
|
|
|
||||||
|
|
@ -40,19 +40,4 @@ public function test_it_reports_a_zero_share_when_nothing_was_counted(): void
|
||||||
|
|
||||||
$this->assertSame(0.0, $result->shareOf($result->rows[0]));
|
$this->assertSame(0.0, $result->shareOf($result->rows[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_has_no_data_without_rows(): void
|
|
||||||
{
|
|
||||||
$this->assertTrue((new BreakdownResult([]))->isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_has_no_data_when_every_row_is_zero(): void
|
|
||||||
{
|
|
||||||
$this->assertTrue((new BreakdownResult([new Breakdown('A', 0)]))->isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_has_data_when_any_row_is_counted(): void
|
|
||||||
{
|
|
||||||
$this->assertFalse((new BreakdownResult([new Breakdown('A', 1)]))->isEmpty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,26 @@ public function test_re_approving_does_not_move_the_decision_time(): void
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_re_rejecting_does_not_move_the_decision_time(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routeArticle->reject();
|
||||||
|
$first = $routeArticle->fresh()->decided_at;
|
||||||
|
|
||||||
|
$this->travel(1)->hours();
|
||||||
|
$routeArticle->fresh()->reject();
|
||||||
|
|
||||||
|
$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
|
public function test_rejecting_an_approved_article_moves_the_decision_time(): void
|
||||||
{
|
{
|
||||||
/** @var RouteArticle $routeArticle */
|
/** @var RouteArticle $routeArticle */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue