Filament stats overview with sparkline trends
A Filament 4 dashboard widget where each stat card shows a 14 day sparkline, a percent change description and a trend colour.
FilamentDashboard widgetsfilamentdashboardstatssparkline
Code
<?php
namespace App\Filament\Widgets;
use App\Models\PageView;
use Filament\Support\Icons\Heroicon;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class TrafficStats extends StatsOverviewWidget
{
protected ?string $pollingInterval = '60s';
private const DAYS = 14;
protected function getStats(): array
{
$views = $this->daily(fn ($query) => $query->selectRaw('date(viewed_at) as day, count(*) as value'));
$visitors = $this->daily(fn ($query) => $query->selectRaw('date(viewed_at) as day, count(distinct visitor_id) as value'));
return [
$this->card('Page views', $views),
$this->card('Unique visitors', $visitors),
];
}
/** Build one card: total, % change versus the previous half, sparkline. */
private function card(string $label, Collection $series): Stat
{
[$previous, $current] = $series->chunk(self::DAYS / 2)->map->sum()->values()->all();
$change = $previous > 0 ? round(($current - $previous) / $previous * 100, 1) : null;
$up = ($change ?? 0) >= 0;
return Stat::make($label, number_format($series->sum()))
->description($change === null ? 'No earlier data' : abs($change).'% '.($up ? 'increase' : 'decrease'))
->descriptionIcon($up ? Heroicon::ArrowTrendingUp : Heroicon::ArrowTrendingDown)
->color($up ? 'success' : 'danger')
->chart($series->all());
}
/** Return exactly DAYS values, filling quiet days with zero so the line is continuous. */
private function daily(callable $select): Collection
{
$from = Carbon::today()->subDays(self::DAYS - 1);
$rows = $select(PageView::query()->where('viewed_at', '>=', $from))
->groupBy('day')
->pluck('value', 'day');
return collect(range(0, self::DAYS - 1))->map(
fn (int $i) => (int) ($rows[$from->copy()->addDays($i)->toDateString()] ?? 0)
);
}
}
Needs: filament/filament@^4.0
How it works
What it solves
Plain stat cards give a number but no sense of direction. This widget pairs each total with a small sparkline, a percent change and a colour, which is what people scan first on an analytics or sales dashboard.
How it works
getStats() builds the cards. daily() runs one grouped query per metric, keyed by day, then maps a fixed 14-day range over it so days without rows become zero. That matters because Stat::chart() simply plots the array you pass, and a gap would squash the line.
card() splits the series into two 7-day halves with chunk(), sums them, and compares the later half to the earlier one. The description text, the arrow icon and the colour all derive from the sign of that change. When the earlier half is empty the percent is null and the card says so instead of dividing by zero.
Adapting it
Replace PageView and the selectRaw closures with your own metric queries (orders, signups, revenue). Change DAYS for a longer window; keep it even so the halves are equal. For a metric where lower is better, such as bounce rate, invert the colour choice.
Pitfalls
date()in SQL is database specific; on PostgreSQL useviewed_at::date.- Day keys come back as strings, so the lookup uses
toDateString(); with a timezone-shifted column, group in the same timezone you build the range in. - Polling re-runs both queries every minute, so index the date column or cache the result.
How to use it
- Put the widget in
app/Filament/Widgets/(Filament auto-discovers it on the default dashboard). - Point the model and the two
selectRawclosures at your own events table. - Make sure the table has a timestamp column (
viewed_at) and an optional visitor key. - Reload the dashboard: each card shows the total, the trend line and the percent change.
- Remove
$pollingIntervalif you do not want automatic refresh.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.