18 KiB
Story 6.2: Analytics Charts
Epic Reference
Epic 6: Admin Dashboard
User Story
As an admin, I want visual charts showing trends and historical data, So that I can identify patterns in client acquisition and consultation outcomes to make informed business decisions.
Prerequisites / Dependencies
This story requires the following to be completed first:
| Dependency | Required From | What's Needed |
|---|---|---|
| Dashboard Layout | Story 6.1 | Admin dashboard page structure and route |
| User Model | Epic 2 | users table with created_at for tracking new clients |
| Consultation Model | Epic 3 | consultations table with consultation_type, status, scheduled_date |
| Admin Layout | Epic 1 | Admin authenticated layout with navigation |
References:
- Epic 6 details:
docs/epics/epic-6-admin-dashboard.md - PRD Analytics Section:
docs/prd.mdSection 11.1 (Analytics & Reporting) - PRD Dashboard Section:
docs/prd.mdSection 5.7 (Admin Dashboard) - Story 6.1 for dashboard structure:
docs/stories/story-6.1-dashboard-overview-statistics.md
Acceptance Criteria
Monthly Trends Chart (Line Chart)
- Display new clients per month (from
users.created_at) - Display consultations per month (from
consultations.scheduled_date) - Two data series on same chart with legend
- X-axis: Month labels (e.g., "Jan 2025", "Feb 2025")
- Y-axis: Count values with appropriate scale
Consultation Breakdown Chart (Pie/Donut)
- Show free vs paid consultation ratio
- Display percentage labels on segments
- Legend showing "Free" and "Paid" with counts
No-show Rate Chart (Line Chart)
- Monthly no-show percentage trend
- X-axis: Month labels
- Y-axis: Percentage (0-100%)
- Visual threshold line at concerning rate (e.g., 20%)
Date Range Selector
- Preset options: Last 6 months, Last 12 months
- Custom date range picker (start/end month)
- Charts update when range changes
- Default selection: Last 6 months
Chart Features
- Tooltips showing exact values on hover
- Responsive sizing (charts resize with viewport)
- Bilingual labels (Arabic/English based on locale)
- Loading state while fetching data
- Empty state when no data available
Design
- Charts section below statistics cards from Story 6.1
- Card-based layout for each chart
- Navy blue and gold color scheme per PRD Section 7.1
- Responsive grid (1 column mobile, 2 columns tablet+)
Technical Implementation
Files to Create/Modify
| File | Purpose |
|---|---|
resources/views/livewire/admin/dashboard.blade.php |
Add charts section to existing dashboard |
app/Services/AnalyticsService.php |
Data aggregation service for chart data |
Integration with Story 6.1 Dashboard
The charts will be added as a new section in the existing dashboard component from Story 6.1. Add below the statistics cards section.
Component Updates (Volt Class-Based)
Add to the existing dashboard component:
<?php
use App\Models\User;
use App\Models\Consultation;
use App\Services\AnalyticsService;
use Livewire\Volt\Component;
new class extends Component {
public string $chartPeriod = '6m';
public ?string $customStart = null;
public ?string $customEnd = null;
public function with(): array
{
return [
// ... existing metrics from Story 6.1 ...
'chartData' => $this->getChartData(),
];
}
public function updatedChartPeriod(): void
{
// Livewire will re-render with new data
}
public function setCustomRange(string $start, string $end): void
{
$this->customStart = $start;
$this->customEnd = $end;
$this->chartPeriod = 'custom';
}
private function getChartData(): array
{
$service = app(AnalyticsService::class);
$months = match($this->chartPeriod) {
'6m' => 6,
'12m' => 12,
'custom' => $this->getCustomMonthCount(),
default => 6,
};
$startDate = $this->chartPeriod === 'custom' && $this->customStart
? Carbon::parse($this->customStart)->startOfMonth()
: now()->subMonths($months - 1)->startOfMonth();
return [
'labels' => $service->getMonthLabels($startDate, $months),
'newClients' => $service->getMonthlyNewClients($startDate, $months),
'consultations' => $service->getMonthlyConsultations($startDate, $months),
'consultationBreakdown' => $service->getConsultationTypeBreakdown($startDate, $months),
'noShowRates' => $service->getMonthlyNoShowRates($startDate, $months),
];
}
private function getCustomMonthCount(): int
{
if (!$this->customStart || !$this->customEnd) {
return 6;
}
return Carbon::parse($this->customStart)
->diffInMonths(Carbon::parse($this->customEnd)) + 1;
}
}; ?>
Analytics Service
Create app/Services/AnalyticsService.php:
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Consultation;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class AnalyticsService
{
public function getMonthLabels(Carbon $startDate, int $months): array
{
return collect(range(0, $months - 1))
->map(fn($i) => $startDate->copy()->addMonths($i)->translatedFormat('M Y'))
->toArray();
}
public function getMonthlyNewClients(Carbon $startDate, int $months): array
{
$endDate = $startDate->copy()->addMonths($months);
$data = User::query()
->whereIn('user_type', ['individual', 'company'])
->whereBetween('created_at', [$startDate, $endDate])
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count")
->groupBy('month')
->pluck('count', 'month');
return $this->fillMonthlyData($startDate, $months, $data);
}
public function getMonthlyConsultations(Carbon $startDate, int $months): array
{
$endDate = $startDate->copy()->addMonths($months);
$data = Consultation::query()
->whereBetween('scheduled_date', [$startDate, $endDate])
->selectRaw("DATE_FORMAT(scheduled_date, '%Y-%m') as month, COUNT(*) as count")
->groupBy('month')
->pluck('count', 'month');
return $this->fillMonthlyData($startDate, $months, $data);
}
public function getConsultationTypeBreakdown(Carbon $startDate, int $months): array
{
$endDate = $startDate->copy()->addMonths($months);
return [
'free' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
->where('consultation_type', 'free')->count(),
'paid' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
->where('consultation_type', 'paid')->count(),
];
}
public function getMonthlyNoShowRates(Carbon $startDate, int $months): array
{
$endDate = $startDate->copy()->addMonths($months);
$results = [];
for ($i = 0; $i < $months; $i++) {
$monthStart = $startDate->copy()->addMonths($i);
$monthEnd = $monthStart->copy()->endOfMonth();
$total = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
->whereIn('status', ['completed', 'no-show'])
->count();
$noShows = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
->where('status', 'no-show')
->count();
$results[] = $total > 0 ? round(($noShows / $total) * 100, 1) : 0;
}
return $results;
}
private function fillMonthlyData(Carbon $startDate, int $months, Collection $data): array
{
return collect(range(0, $months - 1))
->map(fn($i) => $data->get($startDate->copy()->addMonths($i)->format('Y-m'), 0))
->toArray();
}
}
Chart.js Integration with Livewire
Use wire:ignore to prevent Livewire from re-rendering chart canvas, and Alpine.js to manage Chart.js instances:
{{-- Charts Section --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8">
{{-- Date Range Selector --}}
<div class="lg:col-span-2">
<div class="flex flex-wrap gap-2">
<flux:button
wire:click="$set('chartPeriod', '6m')"
:variant="$chartPeriod === '6m' ? 'primary' : 'ghost'"
>
{{ __('Last 6 Months') }}
</flux:button>
<flux:button
wire:click="$set('chartPeriod', '12m')"
:variant="$chartPeriod === '12m' ? 'primary' : 'ghost'"
>
{{ __('Last 12 Months') }}
</flux:button>
{{-- Custom range picker would go here --}}
</div>
</div>
{{-- Monthly Trends Chart --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
<flux:heading size="sm">{{ __('Monthly Trends') }}</flux:heading>
<div
wire:ignore
x-data="trendsChart(@js($chartData))"
x-init="init()"
class="h-64 mt-4"
>
<canvas x-ref="canvas"></canvas>
</div>
</div>
{{-- Consultation Breakdown Chart --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
<flux:heading size="sm">{{ __('Consultation Breakdown') }}</flux:heading>
<div
wire:ignore
x-data="breakdownChart(@js($chartData['consultationBreakdown']))"
x-init="init()"
class="h-64 mt-4"
>
<canvas x-ref="canvas"></canvas>
</div>
</div>
{{-- No-show Rate Chart --}}
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
<flux:heading size="sm">{{ __('No-show Rate Trend') }}</flux:heading>
<div
wire:ignore
x-data="noShowChart(@js($chartData))"
x-init="init()"
class="h-64 mt-4"
>
<canvas x-ref="canvas"></canvas>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('trendsChart', (data) => ({
chart: null,
init() {
this.chart = new Chart(this.$refs.canvas, {
type: 'line',
data: {
labels: data.labels,
datasets: [
{
label: '{{ __("New Clients") }}',
data: data.newClients,
borderColor: '#D4AF37',
tension: 0.3,
},
{
label: '{{ __("Consultations") }}',
data: data.consultations,
borderColor: '#0A1F44',
tension: 0.3,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: { enabled: true }
}
}
});
}
}));
Alpine.data('breakdownChart', (data) => ({
chart: null,
init() {
this.chart = new Chart(this.$refs.canvas, {
type: 'doughnut',
data: {
labels: ['{{ __("Free") }}', '{{ __("Paid") }}'],
datasets: [{
data: [data.free, data.paid],
backgroundColor: ['#D4AF37', '#0A1F44'],
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
}
});
}
}));
Alpine.data('noShowChart', (data) => ({
chart: null,
init() {
this.chart = new Chart(this.$refs.canvas, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: '{{ __("No-show Rate %") }}',
data: data.noShowRates,
borderColor: '#E74C3C',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
fill: true,
tension: 0.3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { min: 0, max: 100 }
}
}
});
}
}));
});
</script>
@endpush
NPM Dependencies
Chart.js can be loaded via CDN (as shown) or installed via npm:
npm install chart.js
Then import in resources/js/app.js:
import Chart from 'chart.js/auto';
window.Chart = Chart;
Edge Cases & Error Handling
| Scenario | Expected Behavior |
|---|---|
| No data for selected period | Show "No data available" message in chart area |
| Only one month of data | Chart renders single point with label |
| Zero consultations (division by zero for no-show rate) | Return 0% no-show rate, not error |
| Very large numbers | Y-axis scales appropriately with Chart.js auto-scaling |
| Custom range spans years | Labels show "Jan 2024", "Jan 2025" to distinguish |
| RTL language (Arabic) | Chart labels render correctly, legend on appropriate side |
| Chart.js fails to load | Show fallback message "Charts unavailable" |
Testing Requirements
Test File
tests/Feature/Admin/AnalyticsChartsTest.php
Test Scenarios
<?php
use App\Models\User;
use App\Models\Consultation;
use App\Services\AnalyticsService;
use Livewire\Volt\Volt;
use Carbon\Carbon;
test('admin can view analytics charts section', function () {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->get(route('admin.dashboard'))
->assertSuccessful()
->assertSee(__('Monthly Trends'));
});
test('chart data returns correct monthly client counts', function () {
// Create clients across different months
User::factory()->create([
'user_type' => 'individual',
'created_at' => now()->subMonths(2),
]);
User::factory()->count(3)->create([
'user_type' => 'individual',
'created_at' => now()->subMonth(),
]);
User::factory()->count(2)->create([
'user_type' => 'company',
'created_at' => now(),
]);
$service = new AnalyticsService();
$data = $service->getMonthlyNewClients(now()->subMonths(2)->startOfMonth(), 3);
expect($data)->toBe([1, 3, 2]);
});
test('consultation breakdown calculates free vs paid correctly', function () {
Consultation::factory()->count(5)->create(['consultation_type' => 'free']);
Consultation::factory()->count(3)->create(['consultation_type' => 'paid']);
$service = new AnalyticsService();
$breakdown = $service->getConsultationTypeBreakdown(now()->subYear(), 12);
expect($breakdown['free'])->toBe(5);
expect($breakdown['paid'])->toBe(3);
});
test('no-show rate calculates correctly', function () {
// Create 8 completed, 2 no-shows = 20% rate
Consultation::factory()->count(8)->create([
'status' => 'completed',
'scheduled_date' => now(),
]);
Consultation::factory()->count(2)->create([
'status' => 'no-show',
'scheduled_date' => now(),
]);
$service = new AnalyticsService();
$rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1);
expect($rates[0])->toBe(20.0);
});
test('no-show rate returns zero when no consultations exist', function () {
$service = new AnalyticsService();
$rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1);
expect($rates[0])->toBe(0);
});
test('date range selector changes chart period', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.dashboard')
->actingAs($admin)
->assertSet('chartPeriod', '6m')
->set('chartPeriod', '12m')
->assertSet('chartPeriod', '12m');
});
test('chart handles empty data gracefully', function () {
$admin = User::factory()->admin()->create();
// No clients or consultations created
$this->actingAs($admin)
->get(route('admin.dashboard'))
->assertSuccessful();
// Should not throw errors
});
test('non-admin cannot access analytics charts', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$this->actingAs($client)
->get(route('admin.dashboard'))
->assertForbidden();
});
Manual Testing Checklist
- Verify charts render on desktop (1200px+)
- Verify charts resize correctly on tablet (768px)
- Verify charts stack vertically on mobile (375px)
- Verify tooltips show exact values on hover
- Verify 6-month button is selected by default
- Verify 12-month button updates all charts
- Verify chart colors match brand (navy #0A1F44, gold #D4AF37)
- Verify charts work in Arabic (RTL) mode
- Verify loading state appears while data fetches
- Verify empty state message when no data
Definition of Done
- All three charts render correctly (trends, breakdown, no-show)
- Date range selector switches between 6/12 months
- Tooltips show exact values on all charts
- Charts are responsive on mobile, tablet, desktop
- Bilingual labels work (Arabic/English)
- Empty state handled gracefully
- No-show rate handles zero consultations (no division error)
- AnalyticsService unit tests pass
- Feature tests for chart data pass
- Code formatted with Pint
- Admin-only access enforced
Estimation
Complexity: Medium-High | Effort: 4-5 hours
Out of Scope
- Custom date range picker with calendar UI (can use simple month selects)
- Exporting charts as images
- Real-time chart updates (polling) - charts update on page load/range change only
- Animated chart transitions