libra/docs/stories/story-6.2-analytics-charts.md

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.md Section 11.1 (Analytics & Reporting)
  • PRD Dashboard Section: docs/prd.md Section 5.7 (Admin Dashboard)
  • Story 6.1 for dashboard structure: docs/stories/story-6.1-dashboard-overview-statistics.md

Acceptance Criteria

  • 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