Release v1.4.0 #146
7 changed files with 197 additions and 19 deletions
|
|
@ -38,8 +38,23 @@ public function isTooWide(): bool
|
|||
return $this->tooWide;
|
||||
}
|
||||
|
||||
/** True only when no axis was built; a real range always has one label per day. */
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return ! $this->tooWide && $this->labels === [];
|
||||
}
|
||||
|
||||
/** True when every value is null or zero — an axis exists but nothing happened on it. */
|
||||
public function hasNoData(): bool
|
||||
{
|
||||
foreach ($this->series as $one) {
|
||||
foreach ($one->values as $value) {
|
||||
if ($value !== null && $value != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
namespace App\Livewire;
|
||||
|
||||
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||
use App\Dashboard\Stats\ArticlesTrend;
|
||||
use App\Dashboard\Stats\BreakdownResult;
|
||||
use App\Dashboard\Stats\BreakdownStat;
|
||||
use App\Dashboard\Stats\PublicationsPerChannel;
|
||||
use App\Dashboard\Stats\SeriesResult;
|
||||
use App\Services\DashboardStatsService;
|
||||
use App\Support\DateRange;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
|
@ -32,6 +34,7 @@ public function mount(): void
|
|||
*/
|
||||
private const RANGE_DEPENDENT_ISLANDS = [
|
||||
'article-statistics',
|
||||
'articles-trend',
|
||||
'articles-per-feed',
|
||||
'publications-per-channel',
|
||||
];
|
||||
|
|
@ -118,6 +121,16 @@ public function articleStats(): array
|
|||
return app(DashboardStatsService::class)->getStats($range);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function articlesTrend(): SeriesResult
|
||||
{
|
||||
$range = $this->range();
|
||||
|
||||
return $range instanceof DateRange
|
||||
? app(ArticlesTrend::class)->for($range)
|
||||
: new SeriesResult([], []);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function articlesPerFeed(): BreakdownResult
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ Chart.register(
|
|||
Tooltip,
|
||||
);
|
||||
|
||||
const palette = ['#3b82f6', '#10b981'];
|
||||
|
||||
export default function trendChart({ labels = [], series = [] } = {}) {
|
||||
return {
|
||||
chart: null,
|
||||
|
|
@ -28,7 +30,17 @@ export default function trendChart({ labels = [], series = [] } = {}) {
|
|||
init() {
|
||||
this.chart = new Chart(this.$refs.canvas, {
|
||||
type: 'line',
|
||||
data: this.data(labels, series),
|
||||
data: {
|
||||
labels,
|
||||
datasets: series.map((one, index) => ({
|
||||
label: one.name,
|
||||
data: one.values,
|
||||
borderColor: palette[index % palette.length],
|
||||
backgroundColor: palette[index % palette.length],
|
||||
spanGaps: false,
|
||||
tension: 0.3,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
|
@ -40,24 +52,6 @@ export default function trendChart({ labels = [], series = [] } = {}) {
|
|||
});
|
||||
},
|
||||
|
||||
data(labels, series) {
|
||||
return {
|
||||
labels,
|
||||
datasets: series.map((one, index) => ({
|
||||
label: one.name,
|
||||
data: one.values,
|
||||
borderColor: this.palette(index),
|
||||
backgroundColor: this.palette(index),
|
||||
spanGaps: false,
|
||||
tension: 0.3,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
palette(index) {
|
||||
return ['#3b82f6', '#10b981'][index % 2];
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.chart?.destroy();
|
||||
this.chart = null;
|
||||
|
|
|
|||
|
|
@ -183,6 +183,19 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
|||
@endisland
|
||||
</div>
|
||||
|
||||
<!-- Articles Fetched vs Published -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
|
||||
|
||||
@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
|
||||
</div>
|
||||
|
||||
<!-- Articles per Feed -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
||||
|
|
|
|||
17
resources/views/livewire/partials/trend-panel.blade.php
Normal file
17
resources/views/livewire/partials/trend-panel.blade.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all()])
|
||||
|
||||
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
|
||||
@if ($result->isTooWide())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $tooWideMessage }}</p>
|
||||
@elseif ($result->isEmpty() || $result->hasNoData())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
|
||||
@else
|
||||
<div
|
||||
wire:key="trend-{{ md5(json_encode($payload)) }}"
|
||||
x-data="trendChart({{ Js::from($payload) }})"
|
||||
class="relative h-72"
|
||||
>
|
||||
<canvas x-ref="canvas"></canvas>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -259,4 +259,90 @@ public function test_it_re_renders_the_article_statistics_island_when_a_preset_i
|
|||
|
||||
$this->assertMatchesRegularExpression('/Articles Fetched.*?>\s*2\s*</s', $fragments);
|
||||
}
|
||||
|
||||
public function test_it_renders_the_trend_chart_on_mount(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||
|
||||
Article::factory()->create(['created_at' => Carbon::parse('2026-07-15 09:00:00')]);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('Fetched vs Published')
|
||||
->assertSee('trendChart(', false)
|
||||
->assertSee('x-ref="canvas"', false);
|
||||
}
|
||||
|
||||
public function test_it_renders_the_trend_chart_in_its_own_island(): void
|
||||
{
|
||||
$fragments = $this->islandFragments(
|
||||
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('name=articles-trend|', $fragments);
|
||||
}
|
||||
|
||||
public function test_it_refreshes_the_trend_island_when_the_range_changes(): void
|
||||
{
|
||||
Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||
|
||||
$fragments = $this->islandFragments(
|
||||
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('name=articles-trend|', $fragments);
|
||||
$this->assertStringContainsString('2026-07-10', $fragments);
|
||||
$this->assertStringContainsString('Fetched', $fragments);
|
||||
$this->assertStringContainsString('Published', $fragments);
|
||||
}
|
||||
|
||||
public function test_it_reports_a_too_wide_range_instead_of_charting_it(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||
|
||||
$fragments = $this->islandFragments(
|
||||
Livewire::test(Dashboard::class)->call('applyPreset', 'all')
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('too wide to chart by day', $fragments);
|
||||
$this->assertStringNotContainsString('x-ref="canvas"', $fragments);
|
||||
}
|
||||
|
||||
public function test_it_reports_an_empty_range_separately_from_a_too_wide_one(): void
|
||||
{
|
||||
$fragments = $this->islandFragments(
|
||||
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('No articles or publications in this range.', $fragments);
|
||||
$this->assertStringNotContainsString('too wide to chart by day', $fragments);
|
||||
}
|
||||
|
||||
public function test_it_keys_the_chart_on_its_data_so_a_range_change_replaces_it(): void
|
||||
{
|
||||
Article::factory()->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||
Article::factory()->create(['created_at' => Carbon::parse('2026-08-10 12:00:00')]);
|
||||
|
||||
$july = $this->islandFragments(
|
||||
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||
);
|
||||
|
||||
$august = $this->islandFragments(
|
||||
Livewire::test(Dashboard::class)->call('applyRange', '2026-08-01', '2026-08-31')
|
||||
);
|
||||
|
||||
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $july, $julyKey);
|
||||
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $august, $augustKey);
|
||||
|
||||
$this->assertNotEmpty($julyKey);
|
||||
$this->assertNotEmpty($augustKey);
|
||||
$this->assertNotSame($julyKey[1], $augustKey[1]);
|
||||
}
|
||||
|
||||
public function test_it_renders_an_empty_trend_for_an_invalid_range(): void
|
||||
{
|
||||
Livewire::test(Dashboard::class)
|
||||
->set('from', 'not-a-date')
|
||||
->assertSee('Fetched vs Published')
|
||||
->assertDontSee('x-ref="canvas"', false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,44 @@ public function test_an_empty_but_valid_result_reports_itself_as_empty(): void
|
|||
|
||||
$this->assertTrue($result->isEmpty());
|
||||
}
|
||||
|
||||
public function test_a_result_whose_series_are_all_zero_has_no_data(): void
|
||||
{
|
||||
$result = new SeriesResult(
|
||||
['2026-07-01', '2026-07-02'],
|
||||
[new Series('Fetched', [0, 0]), new Series('Published', [0, 0])],
|
||||
);
|
||||
|
||||
$this->assertTrue($result->hasNoData());
|
||||
}
|
||||
|
||||
public function test_a_result_with_any_non_zero_value_has_data(): void
|
||||
{
|
||||
$result = new SeriesResult(
|
||||
['2026-07-01', '2026-07-02'],
|
||||
[new Series('Fetched', [0, 0]), new Series('Published', [0, 1])],
|
||||
);
|
||||
|
||||
$this->assertFalse($result->hasNoData());
|
||||
}
|
||||
|
||||
public function test_a_result_of_only_nulls_has_no_data(): void
|
||||
{
|
||||
$result = new SeriesResult(
|
||||
['2026-07-01', '2026-07-02'],
|
||||
[new Series('Approval Rate', [null, null])],
|
||||
);
|
||||
|
||||
$this->assertTrue($result->hasNoData());
|
||||
}
|
||||
|
||||
public function test_zero_values_are_indistinguishable_from_absent_ones(): void
|
||||
{
|
||||
$result = new SeriesResult(
|
||||
['2026-07-01', '2026-07-02'],
|
||||
[new Series('Approval Rate', [null, 0.0])],
|
||||
);
|
||||
|
||||
$this->assertTrue($result->hasNoData());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue